@xemahq/xema 0.45.0 → 0.46.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 (53) hide show
  1. package/dist/commands/biome.d.ts.map +1 -1
  2. package/dist/commands/biome.js +3 -0
  3. package/dist/commands/biome.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +1613 -846
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +516 -474
  6. package/dist/runtime/biome-publish-image-layers.d.ts +20 -0
  7. package/dist/runtime/biome-publish-image-layers.d.ts.map +1 -0
  8. package/dist/runtime/biome-publish-image-layers.js +64 -0
  9. package/dist/runtime/biome-publish-image-layers.js.map +1 -0
  10. package/dist/runtime/biome-publish.d.ts +4 -0
  11. package/dist/runtime/biome-publish.d.ts.map +1 -1
  12. package/dist/runtime/biome-publish.js +25 -5
  13. package/dist/runtime/biome-publish.js.map +1 -1
  14. package/dist/runtime/config/schema.d.ts +4 -3
  15. package/dist/runtime/config/schema.d.ts.map +1 -1
  16. package/dist/runtime/config/schema.js +8 -2
  17. package/dist/runtime/config/schema.js.map +1 -1
  18. package/dist/runtime/infra/infra-catalog.js +1 -1
  19. package/dist/runtime/installation/contract.d.ts +2 -2
  20. package/dist/runtime/installation/contract.d.ts.map +1 -1
  21. package/dist/runtime/installation/contract.js +1 -1
  22. package/dist/runtime/installation/contract.js.map +1 -1
  23. package/edition/README.md +2 -1
  24. package/edition/assemble-producer-input.mjs +2 -0
  25. package/edition/biome-content.mjs +6 -0
  26. package/edition/build-platform-service-index.mjs +2 -0
  27. package/edition/canonical-repository-url.mjs +12 -0
  28. package/edition/cosign-certificate-constraints.mjs +40 -0
  29. package/edition/customer-edition-verbs.mjs +6 -0
  30. package/edition/develop-coverage-readers.mjs +9 -4
  31. package/edition/develop-coverage-run.mjs +2 -1
  32. package/edition/develop-edition.mjs +20 -0
  33. package/edition/develop-record-ancestry.mjs +121 -0
  34. package/edition/develop-select-records.mjs +365 -0
  35. package/edition/distribution-evidence-set.mjs +15 -0
  36. package/edition/edition-config.mjs +27 -1
  37. package/edition/edition-protocol.mjs +8 -1
  38. package/edition/image-evidence.mjs +2 -12
  39. package/edition/kernel-signer-kind.mjs +23 -0
  40. package/edition/lib.mjs +104 -2
  41. package/edition/oci-evidence-verbs.mjs +7 -1
  42. package/edition/oci-image-source-evidence.mjs +129 -21
  43. package/edition/operation-grant-admission.mjs +98 -4
  44. package/edition/operation-requirements.mjs +118 -4
  45. package/edition/plan-image-evidence-refresh.mjs +21 -4
  46. package/edition/platform-service-class.mjs +23 -0
  47. package/edition/platform-service-ownership.mjs +23 -13
  48. package/edition/produce-distribution-completeness-evidence.mjs +2 -0
  49. package/edition/produce-image-source-evidence.mjs +9 -11
  50. package/edition/producer-evidence-bundle.mjs +43 -1
  51. package/edition/record-refused.mjs +11 -0
  52. package/edition/resolve-distribution.mjs +43 -1
  53. package/package.json +3 -3
@@ -0,0 +1,365 @@
1
+ // develop-select-records — the body of `xema edition develop select-records`.
2
+ //
3
+ // For every member of the develop graph, take the newest signed build record the data root's record workflow
4
+ // published for the branch, and write the snapshot it names: the commit to pin, the run it came from, and the
5
+ // images it built. A record is trusted only after cosign verified it, offline against the pinned trusted root,
6
+ // as signed by the data root's `record-build.yaml` at `refs/heads/<branch>`, called by the member's own
7
+ // repository at the record's own commit.
8
+ //
9
+ // "Newest" is the newest COMMIT on develop, never the highest run: every record of the branch is read and
10
+ // verified, the selected commit must equal or descend from every other record's commit (git ancestry, develop
11
+ // fetched from the member), and among that commit's records (re-runs of it) the highest run and attempt wins.
12
+ // With --published-pins <file> (a file this verb wrote; only .pins is read) a member's published commit is kept
13
+ // when the records are not ahead of it, and a diverged one is refused; without it the rule applies among the
14
+ // records only (part 07 passes the flag).
15
+ //
16
+ // Every failure is a hard refusal naming the repository, and nothing is written. Two classes: `RecordRefused`
17
+ // (the record is missing, or cosign or the record itself says no) and a plain Error (the registry or cosign
18
+ // could not answer), which the operator retries rather than reads.
19
+ //
20
+ // Rollback residual: anyone who can delete registry tags can remove newer records, and select-records then pins
21
+ // the newest remaining valid one. The guard is deploy-latest's (part 07): its owner refuses a snapshot that goes
22
+ // backwards. This verb checks nothing against current pins.
23
+
24
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+
28
+ import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
29
+ import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
30
+ import { writeOnce } from './customer-edition-verbs.mjs';
31
+ import { basicAuthParts, defaultRunCosign } from './develop-coverage-readers.mjs';
32
+ import { developGraphMembers } from './develop-graph.mjs';
33
+ import { PublishedRecordMissingError, newestCommitOnDevelop } from './develop-record-ancestry.mjs';
34
+ import { editionWorkspaceConfig } from './edition-config.mjs';
35
+ import { dataRoot } from './edition-workspace.mjs';
36
+ import { loadDeclaredPinnedPolicy } from './evidence-trust-policy.mjs';
37
+ import { COSIGN_ENV, prepareTrustedCosignBinary, validatePinnedTrustedRoot } from './image-evidence.mjs';
38
+ import { compareStrings, loadKernelDistributionSchemas } from './lib.mjs';
39
+ import { RecordRefused } from './record-refused.mjs';
40
+ import { loadRegistryCredentialDeclarations, resolveRegistryCredential, splitImageRef } from './registry-credentials.mjs';
41
+ import { isNotPublishedYet } from './seal-image-digests.mjs';
42
+ import { readStrictJsonFile } from './strict-json.mjs';
43
+ import { assertPreparedExecutable } from './trusted-executable.mjs';
44
+ import { registryClientsFor } from './verify-image-platforms.mjs';
45
+
46
+ /** The repository segment, under the registry root, that holds a member's build records. */
47
+ export const BUILD_RECORDS_SEGMENT = 'build-records';
48
+ export const RECORD_SIGNER_WORKFLOW = '.github/workflows/record-build.yaml';
49
+ export const RECORD_SIGNER_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
50
+ /** The branches a record signer is declared for, each with the ref the record workflow runs at. */
51
+ const RECORD_SIGNER_REFS = Object.freeze({ develop: 'refs/heads/develop' });
52
+ /**
53
+ * A cosign exit whose stderr shows the registry was not reached before any verdict. Only this, a cosign that
54
+ * could not run, and a timeout are operational; every other non-zero exit of `cosign verify` is a verdict.
55
+ */
56
+ const COSIGN_TRANSPORT_FAILURE = /dial tcp|i\/o timeout|connection (refused|reset)|no such host|TLS handshake timeout|context deadline exceeded/i;
57
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
58
+
59
+ /** The record's producer is a kind this CLI has no claims for: a closed set, never guessed at. */
60
+ export class UnknownBuildRecordProducerError extends RecordRefused {
61
+ constructor(repo, kind) {
62
+ super(repo, `the record's producer kind ${JSON.stringify(kind)} is not one select-records can verify`);
63
+ this.name = 'UnknownBuildRecordProducerError';
64
+ }
65
+ }
66
+
67
+ export class DataRootRepositoryUndeclaredError extends Error {
68
+ constructor() {
69
+ super('edition-workspace.json declares no dataRootRepositoryUrl; select-records cannot name the record signer');
70
+ this.name = 'DataRootRepositoryUndeclaredError';
71
+ }
72
+ }
73
+
74
+ export class KernelLacksBuildRecordSchemaError extends Error {
75
+ constructor(missing) {
76
+ super(`the kernel's distribution module does not export ${missing.join(', ')}; select-records needs a kernel with the build-record contract`);
77
+ this.name = 'KernelLacksBuildRecordSchemaError';
78
+ }
79
+ }
80
+
81
+ /** The data root's `owner/name`, from the workspace declaration; refused by name when it declares none. */
82
+ export function declaredDataRootRepositoryUrl() {
83
+ const url = editionWorkspaceConfig().dataRootRepositoryUrl;
84
+ if (url === undefined) {throw new DataRootRepositoryUndeclaredError();}
85
+ return url;
86
+ }
87
+
88
+ /** The kernel's build-record exports, refused as a set when any is absent. */
89
+ export function requireBuildRecordKernel(schemas) {
90
+ const names = ['BUILD_RECORD_MEDIA_TYPE', 'BuildRecordSchema', 'buildRecordTag', 'parseBuildRecordTag', 'compareBuildRecordRuns'];
91
+ const missing = names.filter((name) => schemas?.[name] === undefined);
92
+ if (missing.length > 0 || typeof schemas.BuildRecordSchema?.safeParse !== 'function') {
93
+ throw new KernelLacksBuildRecordSchemaError(missing.length > 0 ? missing : ['a BuildRecordSchema with safeParse']);
94
+ }
95
+ return schemas;
96
+ }
97
+
98
+ /** The ref and data-root repository a branch's records are signed by; refused before any network call. */
99
+ export function recordSignerOrigin({ branch, dataRootRepositoryUrl }) {
100
+ const ref = Object.hasOwn(RECORD_SIGNER_REFS, branch) ? RECORD_SIGNER_REFS[branch] : undefined;
101
+ if (ref === undefined) {throw new Error(`no record signer declared for branch ${branch}`);}
102
+ if (dataRootRepositoryUrl === undefined) {throw new DataRootRepositoryUndeclaredError();}
103
+ const dataRepository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(dataRootRepositoryUrl, 'dataRootRepositoryUrl'), 'dataRootRepositoryUrl');
104
+ return { ref, dataRepository };
105
+ }
106
+
107
+ /**
108
+ * The signer of the branch's records: the data root's record workflow at the branch, as the certificate states
109
+ * it, with the repository that CALLED the workflow (the member) as the workflow-repository claim.
110
+ */
111
+ export function recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl }) {
112
+ const { ref, dataRepository } = recordSignerOrigin({ branch, dataRootRepositoryUrl });
113
+ const member = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(memberRepositoryUrl, 'repositoryUrl'), 'repositoryUrl');
114
+ return {
115
+ certificateIdentity: `https://github.com/${dataRepository}/${RECORD_SIGNER_WORKFLOW}@${ref}`,
116
+ oidcIssuer: RECORD_SIGNER_OIDC_ISSUER,
117
+ githubWorkflow: { repository: member, ref },
118
+ memberRepository: member,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * What a record's producer says, read through the ONE switch over its kind: the repository that called the
124
+ * record workflow (the cosign caller claim, held equal to the member's) and the run the snapshot names.
125
+ */
126
+ export function producerClaims({ record, repo, memberRepository }) {
127
+ switch (record.producer?.kind) {
128
+ case 'github-actions': {
129
+ const { repository, runNumber, runAttempt } = record.producer;
130
+ if (repository !== memberRepository) {throw new RecordRefused(repo, `record is produced by repository ${repository}, not ${memberRepository}`);}
131
+ return { repository, snapshot: `${runNumber}.${runAttempt}` };
132
+ }
133
+ default:
134
+ throw new UnknownBuildRecordProducerError(repo, record.producer?.kind);
135
+ }
136
+ }
137
+
138
+ /** Run one registry client call; ONLY a failure of the client itself is "the registry did not answer". */
139
+ async function fromRegistry(repo, what, call) {
140
+ try {
141
+ return await call();
142
+ } catch (error) {
143
+ throw new Error(`select-records: ${repo}: the registry did not answer for ${what}: ${error.message}`);
144
+ }
145
+ }
146
+
147
+ /** Read one record by its tag: manifest by digest, its single layer, parsed against the kernel's schema. */
148
+ async function readRecord({ kernel, client, repository, repo, tag }) {
149
+ const served = await fromRegistry(repo, `${repository}:${tag}`, () => client.getManifestByTag(repository, tag));
150
+ const manifest = await fromRegistry(repo, `${repository}@${served.digest}`, () => client.getManifestByDigest(repository, served.digest));
151
+ const layers = manifest.document.layers;
152
+ if (!Array.isArray(layers) || layers.length !== 1) {
153
+ throw new RecordRefused(repo, `record ${tag} has ${Array.isArray(layers) ? layers.length : 'no'} layers, not exactly one`);
154
+ }
155
+ const [layer] = layers;
156
+ if (layer.mediaType !== kernel.BUILD_RECORD_MEDIA_TYPE) {
157
+ throw new RecordRefused(repo, `record ${tag} layer media type is ${JSON.stringify(layer.mediaType)}, not ${kernel.BUILD_RECORD_MEDIA_TYPE}`);
158
+ }
159
+ const bytes = await fromRegistry(repo, `${repository}@${layer.digest}`, () => client.getBlob(repository, layer.digest));
160
+ let document;
161
+ try {
162
+ document = JSON.parse(bytes.toString('utf8'));
163
+ } catch (error) {
164
+ throw new RecordRefused(repo, `record ${tag} is not JSON: ${error.message}`);
165
+ }
166
+ const parsed = kernel.BuildRecordSchema.safeParse(document);
167
+ if (!parsed.success) {throw new RecordRefused(repo, `record ${tag} does not parse as a build record: ${parsed.error.message}`);}
168
+ return { record: parsed.data, digest: served.digest };
169
+ }
170
+
171
+ /** The tag's record, verified by digest against the constrained signer, then held to its own repo, branch and tag. */
172
+ async function loadVerifiedRecord({ kernel, client, runCosign, cosignEnv, trustedRootPath, repository, member, branch, dataRootRepositoryUrl, tag }) {
173
+ const repo = member.repo;
174
+ const { record, digest } = await readRecord({ kernel, client, repository, repo, tag });
175
+ const signer = recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl: member.repositoryUrl });
176
+ const { repository: callerRepository, snapshot } = producerClaims({ record, repo, memberRepository: signer.memberRepository });
177
+ signer.githubWorkflow.repository = callerRepository;
178
+ const ref = `${repository}@${digest}`;
179
+ const args = ['verify', '--offline', '--trusted-root', trustedRootPath, ...certificateConstraintArgs(signer, record.commit), ref];
180
+ let result;
181
+ try {
182
+ result = await runCosign(args, cosignEnv);
183
+ } catch (error) {
184
+ // no numeric exit code: cosign did not run (spawn failure) or did not answer (timeout, kill signal)
185
+ throw new Error(`select-records: ${repo}: cosign could not run for ${ref}: ${error.message}`);
186
+ }
187
+ if (result.code !== 0) {
188
+ if (COSIGN_TRANSPORT_FAILURE.test(result.stderr)) {
189
+ throw new Error(`select-records: ${repo}: cosign could not reach the registry for ${ref} (exit ${result.code}): ${result.stderr.trim()}`);
190
+ }
191
+ throw new RecordRefused(repo, `cosign refused record ${tag} (${digest}) as signed by ${signer.certificateIdentity} (exit ${result.code}): ${result.stderr.trim()}`);
192
+ }
193
+ if (record.branch !== branch) {throw new RecordRefused(repo, `record ${tag} is for branch ${record.branch}, not ${branch}`);}
194
+ if (kernel.buildRecordTag(record) !== tag) {throw new RecordRefused(repo, `record tagged ${tag} says it is ${kernel.buildRecordTag(record)}`);}
195
+ return { record, snapshot };
196
+ }
197
+
198
+ /**
199
+ * @param {object} request
200
+ * @param {string} request.branch
201
+ * @param {string} request.registry the registry root, e.g. `registry.example.com/acme`
202
+ * @param {string} [request.at] ISO-8601 UTC: the newest record completed at or before it
203
+ * @param {readonly {repo: string, repositoryUrl: string}[]} request.members
204
+ * @param {string|undefined} request.dataRootRepositoryUrl
205
+ * @param {object} request.kernel the kernel's distribution exports
206
+ * @param {(authority: string) => object} request.clientFor registry client of an authority
207
+ * @param {(args: string[], env: object) => Promise<{code: number, stdout: string, stderr: string}>} request.runCosign
208
+ * @param {(authority: string) => object} request.cosignEnvFor
209
+ * @param {string} request.trustedRootPath
210
+ * @param {Record<string, string>} [request.publishedPins] member repo -> the commit already published (--published-pins)
211
+ * @param {object} request.workspace the workspace config the source hold reads
212
+ * @param {object} request.env
213
+ * @param {Function} [request.runGit] replaced by tests
214
+ * @returns {Promise<{pins: object, snapshot: object, images: object[]}>}
215
+ */
216
+ export async function selectRecords({ branch, registry, at, members, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath, publishedPins, workspace, env, runGit }) {
217
+ requireBuildRecordKernel(kernel);
218
+ recordSignerOrigin({ branch, dataRootRepositoryUrl });
219
+ if (at !== undefined && (!ISO_UTC.test(at) || Number.isNaN(Date.parse(at)))) {
220
+ throw new Error(`--at must be an ISO-8601 UTC time (YYYY-MM-DDTHH:MM:SSZ); got ${JSON.stringify(at)}`);
221
+ }
222
+ const selected = [];
223
+ for (const member of members) {
224
+ const repository = `${registry}/${BUILD_RECORDS_SEGMENT}/${member.repo.split('/').at(-1)}`;
225
+ const { authority } = splitImageRef(repository);
226
+ const client = clientFor(authority);
227
+ let tags;
228
+ try {
229
+ tags = await client.listTags(repository);
230
+ } catch (error) {
231
+ if (isNotPublishedYet(error)) {throw new RecordRefused(member.repo, `${repository} does not exist: no build record was ever published`);}
232
+ throw new Error(`select-records: ${member.repo}: the registry did not answer for ${repository} tags: ${error.message}`);
233
+ }
234
+ const runs = tags
235
+ .map((tag) => ({ tag, run: kernel.parseBuildRecordTag(tag, branch) }))
236
+ .filter(({ run }) => run !== undefined)
237
+ .sort((a, b) => kernel.compareBuildRecordRuns(b.run, a.run));
238
+ if (runs.length === 0) {throw new RecordRefused(member.repo, `${repository} has no build record for branch ${branch}`);}
239
+ // EVERY candidate is read and verified; any that fails refuses the run. None is skipped.
240
+ const verified = [];
241
+ for (const { tag, run } of runs) {
242
+ const loaded = await loadVerifiedRecord({
243
+ kernel, client, runCosign, cosignEnv: { ...COSIGN_ENV, ...cosignEnvFor(authority) }, trustedRootPath,
244
+ repository, member, branch, dataRootRepositoryUrl, tag,
245
+ });
246
+ verified.push({ tag, run, ...loaded });
247
+ }
248
+ const eligible = at === undefined ? verified : verified.filter(({ record }) => Date.parse(record.completedAt) <= Date.parse(at));
249
+ if (eligible.length === 0) {throw new RecordRefused(member.repo, `no build record for branch ${branch} completed at or before ${at}`);}
250
+ const published = publishedPins?.[member.repo];
251
+ const commit = newestCommitOnDevelop({
252
+ member, candidates: eligible.map(({ record, tag }) => ({ commit: record.commit, tag })), published, workspace, env, runGit,
253
+ });
254
+ // records of one commit are re-runs of it: the highest run, then attempt
255
+ const ofCommitIn = (pool) => pool.filter(({ record }) => record.commit === commit).sort((a, b) => kernel.compareBuildRecordRuns(b.run, a.run));
256
+ // a kept published commit may be one --at left out of the eligible records: its own verified record stands
257
+ const ofCommit = ofCommitIn(eligible).length > 0 ? ofCommitIn(eligible) : ofCommitIn(verified);
258
+ if (ofCommit.length === 0) {throw new PublishedRecordMissingError(member.repo, commit);}
259
+ const chosen = ofCommit[0];
260
+ selected.push({ member, ...chosen });
261
+ }
262
+ return composeSelection(selected);
263
+ }
264
+
265
+ /** The snapshot a selection writes. A name built by two records is refused: an image has one builder. */
266
+ export function composeSelection(selected) {
267
+ const pins = {};
268
+ const snapshot = {};
269
+ const owners = new Map();
270
+ const images = [];
271
+ for (const { member, record, snapshot: run } of selected) {
272
+ pins[member.repo] = record.commit;
273
+ snapshot[member.repo] = run;
274
+ for (const image of record.images) {
275
+ if (owners.has(image.name)) {
276
+ throw new RecordRefused(member.repo, `image ${image.name} is also built by ${owners.get(image.name)}; an image has one builder`);
277
+ }
278
+ owners.set(image.name, member.repo);
279
+ images.push({ name: image.name, digest: image.digest, contentHash: image.contentHash });
280
+ }
281
+ }
282
+ const sorted = (object) => Object.fromEntries(Object.entries(object).sort(([a], [b]) => compareStrings(a, b)));
283
+ return { pins: sorted(pins), snapshot: sorted(snapshot), images: images.sort((a, b) => compareStrings(a.name, b.name)) };
284
+ }
285
+
286
+ /** The `pins` of a file this verb wrote: member repo -> 40-hex commit. Nothing else of the file is read. */
287
+ export function readPublishedPins(path) {
288
+ const document = readStrictJsonFile(path);
289
+ const { pins } = document ?? {};
290
+ if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
291
+ throw new Error(`--published-pins ${path} has no "pins" object`);
292
+ }
293
+ for (const [repo, commit] of Object.entries(pins)) {
294
+ if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
295
+ throw new Error(`--published-pins ${path}: pin ${JSON.stringify(repo)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
296
+ }
297
+ }
298
+ return pins;
299
+ }
300
+
301
+ /** The `select-records` verb. Writes `--out` once, only after every member was selected. */
302
+ export async function runSelectRecords({
303
+ options,
304
+ env = process.env,
305
+ loadPolicy = loadDeclaredPinnedPolicy,
306
+ prepareCosign = prepareTrustedCosignBinary,
307
+ loadKernel = loadKernelDistributionSchemas,
308
+ trustedRootBytes = (root, pinnedPolicy) => validatePinnedTrustedRoot(root, pinnedPolicy).bytes,
309
+ cosignPathOf = (handle) => assertPreparedExecutable(handle, 'cosign'),
310
+ members = developGraphMembers(),
311
+ runCosign: injectedRunCosign,
312
+ clientsOverride,
313
+ runGit,
314
+ write = writeOnce,
315
+ }) {
316
+ const dataRootRepositoryUrl = declaredDataRootRepositoryUrl();
317
+ const { policy, dataRoot: policyRoot } = await loadPolicy();
318
+ const kernel = requireBuildRecordKernel(await loadKernel());
319
+ const trustedCosign = prepareCosign({ cosignBinary: options['--cosign'], policy });
320
+ const cosignPath = cosignPathOf(trustedCosign);
321
+ const runCosign = injectedRunCosign ?? ((args, cosignEnv) => defaultRunCosign(cosignPath, args, cosignEnv));
322
+ const scratch = mkdtempSync(join(tmpdir(), 'xema-select-records-'));
323
+ try {
324
+ const trustedRootPath = join(scratch, 'trusted-root.json');
325
+ writeFileSync(trustedRootPath, trustedRootBytes(policyRoot, policy), { flag: 'wx', mode: 0o600 });
326
+ const declarations = clientsOverride === undefined ? loadRegistryCredentialDeclarations(dataRoot()) : null;
327
+ const clients = new Map();
328
+ const clientFor = (authority) => {
329
+ if (clientsOverride !== undefined) {return clientsOverride(authority);}
330
+ if (!clients.has(authority)) {
331
+ clients.set(authority, registryClientsFor([{ imageRef: `${authority}/x` }], { declarations, env }).get(authority));
332
+ }
333
+ return clients.get(authority);
334
+ };
335
+ // cosign gets the SAME declared credential oras uses, in its environment, never argv.
336
+ const cosignEnvFor = (authority) => {
337
+ if (clientsOverride !== undefined) {return {};}
338
+ const credential = resolveRegistryCredential({ authority, declarations, env });
339
+ if (credential.access === 'anonymous') {return {};}
340
+ const { username, password } = basicAuthParts(credential);
341
+ return { COSIGN_REGISTRY_USERNAME: username, COSIGN_REGISTRY_PASSWORD: password };
342
+ };
343
+ const selection = await selectRecords({
344
+ branch: options['--branch'],
345
+ registry: options['--registry'],
346
+ at: options['--at'],
347
+ members,
348
+ dataRootRepositoryUrl,
349
+ kernel,
350
+ clientFor,
351
+ runCosign,
352
+ cosignEnvFor,
353
+ trustedRootPath,
354
+ publishedPins: options['--published-pins'] === undefined ? undefined : readPublishedPins(options['--published-pins']),
355
+ workspace: editionWorkspaceConfig(),
356
+ env,
357
+ runGit,
358
+ });
359
+ write(options['--out'], selection);
360
+ return selection;
361
+ } finally {
362
+ rmSync(scratch, { recursive: true, force: true });
363
+ trustedCosign.dispose();
364
+ }
365
+ }
@@ -61,7 +61,9 @@ import { compareStrings } from './lib.mjs';
61
61
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
62
62
  import {
63
63
  createRegistryClientResolver,
64
+ describeInvalidReferrer,
64
65
  resolveImageSourceEvidenceReferrer,
66
+ writeInvalidReferrerReport,
65
67
  } from './oci-image-source-evidence.mjs';
66
68
  import { BUNDLE_MEDIA_TYPE, canonicalSignedBundleBytes, cosignVerifyAttestationArgs, validateProducedBundle } from './produce-image-source-evidence.mjs';
67
69
  import { splitImageRef } from './registry-credentials.mjs';
@@ -494,12 +496,25 @@ export async function resolveProducerEvidence({
494
496
  const { authority, repository } = splitImageRef(subject.imageRef);
495
497
  const client = clientFor(authority);
496
498
  const image = await client.getManifestByTag(repository, subject.contentHash);
499
+ const invalidReferrers = [];
497
500
  const ociEvidence = await resolveImageSourceEvidenceReferrer({
498
501
  client,
499
502
  imageRepository: subject.imageRef,
500
503
  subjectDigest: image.digest,
501
504
  schemas,
505
+ expectedContractAuthority,
506
+ onInvalidReferrer: (skipped) => {
507
+ invalidReferrers.push(skipped);
508
+ writeInvalidReferrerReport(skipped);
509
+ },
502
510
  });
511
+ if (ociEvidence === null && invalidReferrers.length > 0) {
512
+ throw new Error(
513
+ `no valid OCI image-source evidence referrer exists for "${subject.kind}:${subject.id}" ` +
514
+ `at ${subject.imageRef}@${image.digest}: ` +
515
+ invalidReferrers.map(describeInvalidReferrer).join('; '),
516
+ );
517
+ }
503
518
  if (ociEvidence === null) {
504
519
  throw new Error(
505
520
  `no OCI image-source evidence referrer exists for "${subject.kind}:${subject.id}" ` +
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { join } from 'node:path';
9
9
 
10
- import { canonicalPublicHttpsRepositoryUrl } from './canonical-repository-url.mjs';
10
+ import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
11
11
  import { dataRoot } from './edition-workspace.mjs';
12
12
  import { isUnderSourcePrefix } from './git-source-auth.mjs';
13
13
  import { readStrictJsonFile } from './strict-json.mjs';
@@ -49,6 +49,7 @@ const KEYS = Object.freeze([
49
49
  'chartRegistry',
50
50
  'chartPublishers',
51
51
  'sourceRepositoryUrlPrefix',
52
+ 'dataRootRepositoryUrl',
52
53
  'sourceAuthUsername',
53
54
  'evidenceTrustPolicy',
54
55
  ...Object.keys(SECTIONS),
@@ -187,6 +188,30 @@ function assertFirstPartyUrls(firstPartySources, prefix) {
187
188
  }
188
189
  }
189
190
 
191
+ /**
192
+ * The OPTIONAL `dataRootRepositoryUrl`: where the data root itself lives, in the canonical public HTTPS form
193
+ * and under `sourceRepositoryUrlPrefix` like a first-party repository. It names the record signer
194
+ * (the data root's reusable workflows); a data root that does not declare it simply cannot be named as one.
195
+ */
196
+ function optionalDataRootRepositoryUrl(document, prefix) {
197
+ if (document.dataRootRepositoryUrl === undefined) {return {};}
198
+ const where = `${EDITION_WORKSPACE_CONFIG_FILE}: "dataRootRepositoryUrl"`;
199
+ let canonical;
200
+ try {
201
+ canonical = canonicalPublicHttpsRepositoryUrl(document.dataRootRepositoryUrl, where);
202
+ repositoryOwnerName(canonical, where);
203
+ } catch (err) {
204
+ throw new EditionWorkspaceConfigError(err.message);
205
+ }
206
+ if (canonical !== document.dataRootRepositoryUrl) {
207
+ throw new EditionWorkspaceConfigError(`${where} ${JSON.stringify(document.dataRootRepositoryUrl)} is not in its canonical form (${canonical})`);
208
+ }
209
+ if (!isUnderSourcePrefix(canonical, prefix)) {
210
+ throw new EditionWorkspaceConfigError(`${where} ${JSON.stringify(canonical)} is not under sourceRepositoryUrlPrefix ${prefix}`);
211
+ }
212
+ return { dataRootRepositoryUrl: canonical };
213
+ }
214
+
190
215
  /** Validate a parsed config document; returns the frozen, typed config. */
191
216
  export function parseEditionWorkspaceConfig(document) {
192
217
  if (document === null || typeof document !== 'object' || Array.isArray(document)) {
@@ -210,6 +235,7 @@ export function parseEditionWorkspaceConfig(document) {
210
235
  imageRegistry: requireString(document, 'imageRegistry', IMAGE_REGISTRY, '<host>/<namespace> with no scheme or trailing slash'),
211
236
  ...optionalChartSet(document),
212
237
  sourceRepositoryUrlPrefix,
238
+ ...optionalDataRootRepositoryUrl(document, sourceRepositoryUrlPrefix),
213
239
  sourceAuthUsername: requireSourceAuthUsername(document),
214
240
  evidenceTrustPolicy: requireEvidenceTrustPolicy(document),
215
241
  });
@@ -28,7 +28,7 @@ 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. v10..v20 grow the kernel
31
+ /** The edition protocols this library interprets: version -> content digest. v10..v27 grow the kernel
32
32
  * source closure only (entry subpaths and hashing unchanged), so one library speaks every row. */
33
33
  export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
34
34
  Object.freeze({ version: 9, contentDigest: '7cd6d760e7e7caaec3634a08c739debbdeeeaac4f1bad6791246a12f4f248e37' }),
@@ -41,6 +41,13 @@ export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
41
41
  Object.freeze({ version: 16, contentDigest: '2e65c280a399304ebb438e4e9829bcb4688f36a3c6a5e8b35b679cf69c689f55' }),
42
42
  Object.freeze({ version: 19, contentDigest: '66dab3dcf18c3a587f073808cc91165cc513eabb237b35439ff6cfe2b312b127' }),
43
43
  Object.freeze({ version: 20, contentDigest: '22c0e4ba6ee2771e91516ea78806117b5e3e60b76e085b9a9b98e993c1de79f8' }),
44
+ Object.freeze({ version: 21, contentDigest: 'f6364cf4830dcd15f82fb316ec9c0f0c11ffbe45262d97d8621242117d02f2b8' }),
45
+ Object.freeze({ version: 22, contentDigest: '566a0c37ee8b25c22bb9a5979190508e479c87593790b4a9e481ab1db8f7bcd1' }),
46
+ Object.freeze({ version: 23, contentDigest: '96735bd71236b4d0a2cef8a59c4a8d4f6919c6b5e54654c4b2b01e22b9880e04' }),
47
+ Object.freeze({ version: 24, contentDigest: '6ae70f55db789f95e194f623525b30ffedc21478ec17d246336b9a2783b6c0db' }),
48
+ Object.freeze({ version: 25, contentDigest: '79916e652836c7f6c31a7284c083c4f11249c6f21ef0b1bf79efc9dc23684d4a' }),
49
+ Object.freeze({ version: 26, contentDigest: '8d1dcfca5c59ff7b5eee6ea84fec3d9d62a48f3260f4ddb27b6665c25056f968' }),
50
+ Object.freeze({ version: 27, contentDigest: 'd4807f6464508c1abb32f7de9754f2e1f12cd7f2be5fbf939b6a5175c01dd90b' }),
44
51
  ]);
45
52
 
46
53
  /** Where a kernel package publishes its identity and its contract, package-relative. */
@@ -17,6 +17,7 @@ import {
17
17
  } from 'node:path';
18
18
  import { TextDecoder } from 'node:util';
19
19
 
20
+ import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
20
21
  import { assertCanonicalDistributionId } from './distribution-paths.mjs';
21
22
  import { effectiveRepositoryPins, manifestSourcePins } from './edition-source-pins.mjs';
22
23
  import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
@@ -804,21 +805,10 @@ export function verifySigstoreEvidenceDescriptor({
804
805
  verifiedTrustedRootPath,
805
806
  '--bundle',
806
807
  verifiedBundlePath,
807
- '--certificate-identity',
808
- candidate.certificateIdentity,
809
- '--certificate-oidc-issuer',
810
- candidate.oidcIssuer,
808
+ ...certificateConstraintArgs(candidate, verifiedWorkflowSha),
811
809
  '--type',
812
810
  predicateType,
813
811
  ];
814
- args.push(
815
- '--certificate-github-workflow-repository',
816
- candidate.githubWorkflow.repository,
817
- '--certificate-github-workflow-ref',
818
- candidate.githubWorkflow.ref,
819
- '--certificate-github-workflow-sha',
820
- verifiedWorkflowSha,
821
- );
822
812
  execFileSync(trustedCosignPath, args, {
823
813
  env: COSIGN_ENV,
824
814
  stdio: ['ignore', 'ignore', 'pipe'],
@@ -0,0 +1,23 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import { assertKernelEditionProtocol } from './edition-protocol.mjs';
4
+ import { kernelDistPath, kernelPackageDir } from './edition-workspace.mjs';
5
+
6
+ const requireFromHere = createRequire(import.meta.url);
7
+
8
+ /**
9
+ * The kind of signer this library produces and verifies: a keyless OIDC
10
+ * certificate, `SignerKind.OidcCertificate` of the built kernel. The producer
11
+ * inputs and the trust-policy rows the evidence verbs handle carry it.
12
+ */
13
+ export function oidcCertificateSignerKind() {
14
+ assertKernelEditionProtocol(kernelPackageDir());
15
+ const { SignerKind } = requireFromHere(kernelDistPath('distribution', 'index.js'));
16
+ const kind = SignerKind?.OidcCertificate;
17
+ if (typeof kind !== 'string' || kind.length === 0) {
18
+ throw new Error(
19
+ `The built kernel distribution module at ${kernelDistPath('distribution', 'index.js')} does not export SignerKind.OidcCertificate; rebuild the kernel checkout.`,
20
+ );
21
+ }
22
+ return kind;
23
+ }