@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
@@ -41,10 +41,14 @@
41
41
  // the same for a customer edition (id = the definition's; the tag covers the pinned catalogue digest).
42
42
  // freeze-graph --data-sha <sha> --kernel-sha <sha>
43
43
  // --deploy-sha <sha> [--editions-root-url <https url>]
44
- // [--overrides <json>] --out <file>
44
+ // [--overrides <json> | --member-pins <selection file>] --out <file>
45
45
  // resolve refs/heads/develop once per develop-graph repository (the data root's
46
46
  // firstPartySources, at each declared URL) and the editions root; the three checked-out
47
- // repositories are pinned at what the workflow checked out; write the pins. --kernel-sha is refused
47
+ // repositories are pinned at what the workflow checked out; write the pins. --member-pins takes a select-records
48
+ // selection (only .pins, 40-hex, is read) and pins each graph member at its selected record commit instead of its
49
+ // develop head: the selection must pin exactly the graph minus its currentResolution members (which must equal
50
+ // the graph members' declared currentResolution), a missing or extra member is refused by name, and a currentResolution
51
+ // member is pinned at its develop head. --member-pins together with --overrides is refused. --kernel-sha is refused
48
52
  // unless its declared edition protocol is one this CLI supports (the check choose-kernel applies to a pin).
49
53
  // check-existing <id> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
50
54
  // refuse to reuse a pulled artifact whose descriptor disagrees with this snapshot.
@@ -71,7 +75,7 @@
71
75
  // for every develop-graph member, take the newest build record the data root's record workflow signed for
72
76
  // the branch (<root>/build-records/<name>, verified offline by digest against the pinned trusted root, as
73
77
  // signed by <data root>/.github/workflows/record-build.yaml at refs/heads/<branch>, called by the member at the
74
- // record's own commit) and write { pins, snapshot, images }, once (wx). Any missing, unsigned, mismatched or
78
+ // record's own commit) and write { pins, snapshot, behind, images, currentResolution }, once (wx); a member that keeps its current resolution (a declared currentResolution, develop-current-resolution.mjs) is skipped with no registry read, listed in currentResolution, and printed as `xema-docs: current resolution (leaves the edition, XEMA-2373)`; behind is { <repo>: { head, commits } }, develop's head and how many commits the selected one trails it by, and one line per member `<short name>: record <commit>, <N> commits behind HEAD` goes to stdout (always `commits`; short name = the repo's last path segment, refused when two members share one); images[] entries carry `member`, the owning repo handle. Any missing, unsigned, mismatched or
75
79
  // unreadable record refuses the whole run and writes nothing: a newer bad record is never skipped for an older
76
80
  // one. --at takes the newest record completed at or before that time. Only `develop` has a declared signer; the
77
81
  // data root must declare dataRootRepositoryUrl. Takes the roots `resolve` takes (no registry root is implied).
@@ -83,6 +87,14 @@
83
87
  // part 07 passes the flag.
84
88
  // Residual: anyone who can delete registry tags can remove newer records, and this then pins the newest remaining
85
89
  // valid one; the guard is part 07's, deploy-latest's owner refuses a snapshot that goes backwards.
90
+ // image-record <id> --selection <file> --pins <file> --out <file>
91
+ // write the edition's image build record (the file `resolve --images-from` reads) from a select-records selection:
92
+ // the subjects are the checked-out snapshot's first-party image subjects (enumerated as `coverage` does). A subject of
93
+ // a member that keeps its current resolution (a declared currentResolution, develop-current-resolution.mjs) is probed as resolve probes it
94
+ // and goes in `images`; every other first-party subject takes the selection's image of its develop repository, whose
95
+ // selected commit must be the snapshot's, and goes in `satellites`. Platforms are read from the registry by digest.
96
+ // Any subject without an image, any commit mismatch refuses the whole run and writes nothing, once (wx). Third-party
97
+ // subjects are ignored. Takes the roots `resolve` takes.
86
98
  // choose-kernel [--kernel-pin <40-hex>] --out <file>
87
99
  // choose the develop kernel commit in the --kernel checkout (full history; its refs/remotes/origin/develop is the develop head):
88
100
  // the newest first-parent commit whose declared edition protocol this CLI supports
@@ -106,14 +118,17 @@
106
118
  import { resolve } from 'node:path';
107
119
 
108
120
  import { writeCatalogue } from './catalogue.mjs';
109
- import { CUSTOMER_ROOTS, IMAGES_FROM_FLAG, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
121
+ import { CUSTOMER_ROOTS, FIRST_SEAL_FLAG, IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
110
122
  import { DEFINITION_FLAG } from './customer-flags.mjs';
111
123
  import { runDevelopCoverage } from './develop-coverage-run.mjs';
112
124
  import { CoverageExit } from './develop-coverage.mjs';
113
125
  import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
114
126
  import { resolveDevelopEngineImage } from './develop-engine-image.mjs';
115
127
  import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
128
+ import { developGraphMembers } from './develop-graph.mjs';
129
+ import { runImageRecord } from './develop-image-record.mjs';
116
130
  import { runSelectRecords } from './develop-select-records.mjs';
131
+ import { readSelectionPins } from './develop-selection-pins.mjs';
117
132
  import { runVerifyImageRecord } from './develop-verify-image-record.mjs';
118
133
  import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
119
134
  import { EditionChannel, EditionKind } from './edition-constants.mjs';
@@ -124,6 +139,7 @@ import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protoc
124
139
  import { loadKernelDistributionSchemas } from './lib.mjs';
125
140
  import { snapshotPinSource } from './pin-source.mjs';
126
141
  import { composeEdition } from './resolve-distribution.mjs';
142
+ import { resolvePreviousSeal } from './resolve-images.mjs';
127
143
  import { readStrictJsonFile } from './strict-json.mjs';
128
144
 
129
145
  // The roots a verb takes. A customer form (it names --definition) takes fewer, and never the kernel (the
@@ -167,8 +183,8 @@ function parseOverrides(text) {
167
183
  }
168
184
 
169
185
  /** The target-tag input of `--images-from`: the record's digest, or nothing on the probe path. */
170
- function imageBuildRecordTagInput(options) {
171
- return options[IMAGES_FROM_FLAG] === undefined ? {} : { imageBuildRecordDigest: imageBuildRecordDigest(readImageBuildRecord(options[IMAGES_FROM_FLAG])) };
186
+ async function imageBuildRecordTagInput(options) {
187
+ return options[IMAGES_FROM_FLAG] === undefined ? {} : { imageBuildRecordDigest: imageBuildRecordDigest(await readImageBuildRecord(options[IMAGES_FROM_FLAG])) };
172
188
  }
173
189
 
174
190
  async function run(argv, env) {
@@ -176,7 +192,7 @@ async function run(argv, env) {
176
192
  switch (command) {
177
193
  case 'target': {
178
194
  if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
179
- console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'], ...imageBuildRecordTagInput(options) }));
195
+ console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'], ...(await imageBuildRecordTagInput(options)) }));
180
196
  return;
181
197
  }
182
198
  case 'resolve': {
@@ -190,12 +206,13 @@ async function run(argv, env) {
190
206
  env,
191
207
  engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
192
208
  cosignBinary: options['--cosign'],
209
+ previousLock: await resolvePreviousSeal({ distributionId: id, previousLockPath: options[PREVIOUS_LOCK_FLAG], previousLockSha256: options[PREVIOUS_LOCK_SHA256_FLAG], firstSeal: Boolean(options[FIRST_SEAL_FLAG]) }),
193
210
  ...(options[IMAGES_FROM_FLAG] === undefined ? {} : {
194
- imagesFrom: readImageBuildRecord(options[IMAGES_FROM_FLAG]),
211
+ imagesFrom: await readImageBuildRecord(options[IMAGES_FROM_FLAG]),
195
212
  verifySatellites: createSatelliteVerifier({ registry: options['--registry'], cosignBinary: options['--cosign'], env }),
196
213
  }),
197
214
  });
198
- const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage, ...imageBuildRecordTagInput(options) });
215
+ const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage, ...(await imageBuildRecordTagInput(options)) });
199
216
  const outDir = resolve(options['--out']);
200
217
  writeEditionArtifact({
201
218
  outDir,
@@ -232,6 +249,9 @@ async function run(argv, env) {
232
249
  case 'select-records':
233
250
  await runSelectRecords({ options, env });
234
251
  return;
252
+ case 'image-record':
253
+ await runImageRecord({ id, options, env });
254
+ return;
235
255
  case 'verify-image-record':
236
256
  await runVerifyImageRecord({ options, env });
237
257
  return;
@@ -265,6 +285,7 @@ async function run(argv, env) {
265
285
  deploySha: options['--deploy-sha'],
266
286
  editionsRootUrl: options['--editions-root-url'],
267
287
  overrides: parseOverrides(options['--overrides']),
288
+ ...(options['--member-pins'] === undefined ? {} : { memberPins: readSelectionPins(options['--member-pins'], '--member-pins', developGraphMembers()) }),
268
289
  ...headResolver(EditionChannel.Develop, env),
269
290
  });
270
291
  writeOnce(options['--out'], pins);
@@ -9,6 +9,9 @@
9
9
  // freezeGraph the graph repositories, the data root, the kernel, the deploy checkout, the editions root
10
10
  // freezeSources one pin per `sources[]` entry the chain declares
11
11
  //
12
+ // With `memberPins` (a select-records selection) the members are pinned at the selected build records instead; the one
13
+ // declared exception, a member that keeps its current resolution, is pinned at its develop head.
14
+ //
12
15
  // `resolveHead({ name, url })` is the one network seam (default: `git ls-remote` through
13
16
  // git-source-auth: the data root's token only for a repository under its
14
17
  // `sourceRepositoryUrlPrefix`, no credential for any other, never argv).
@@ -16,6 +19,7 @@
16
19
  import { execFileSync } from 'node:child_process';
17
20
 
18
21
  import { canonicalPublicHttpsRepositoryUrl } from './canonical-repository-url.mjs';
22
+ import { currentResolutionOf } from './develop-current-resolution.mjs';
19
23
  import { developGraphMembers } from './develop-graph.mjs';
20
24
  import { EditionChannel } from './edition-channel.mjs';
21
25
  import { assertCanonicalSourceUrl, assertSourceDeclaresNoCredential, isUnderSourcePrefix, withSourceGitEnv } from './git-source-auth.mjs';
@@ -124,6 +128,17 @@ function assertOverrides(overrides, allowed) {
124
128
  }
125
129
  }
126
130
 
131
+ /** The member pins name exactly the graph minus the current-resolution members; a missing or extra member is refused by name. */
132
+ function assertMemberPinsAreTheGraph(memberPins, members) {
133
+ const current = new Set(Object.keys(currentResolutionOf(members)));
134
+ const expected = members.map(({ repo }) => repo).filter((repo) => !current.has(repo));
135
+ const missing = expected.filter((repo) => !Object.hasOwn(memberPins, repo));
136
+ const extra = Object.keys(memberPins).filter((repo) => !expected.includes(repo));
137
+ if (missing.length > 0 || extra.length > 0) {
138
+ throw new Error(`--member-pins must pin exactly the develop graph minus ${JSON.stringify([...current])}: missing ${JSON.stringify(missing)}, extra ${JSON.stringify(extra)}`);
139
+ }
140
+ }
141
+
127
142
  const GRAPH_PHASE = new Set([
128
143
  SnapshotPinNamespace.Graph,
129
144
  SnapshotPinNamespace.Distributions,
@@ -149,6 +164,9 @@ function assertCheckedOutPin({ key, sha, flag, what, overrides }) {
149
164
  * @param {string} request.kernelSha the commit the kernel checkout is at
150
165
  * @param {string} request.deploySha the commit the deploy checkout is at
151
166
  * @param {string} [request.editionsRootUrl] https URL of the editions root; absent = this repository
167
+ * @param {Record<string, string>} [request.memberPins] the graph members' pins from a selection (`--member-pins`): exactly the
168
+ * graph minus the members that keep their current resolution (develop-current-resolution.mjs), which are pinned at their
169
+ * develop head; refused together with overrides
152
170
  * @param {(source: {name: string, url: string}) => void} request.holdSource the hold of one source; runs for ALL graph sources before the first head
153
171
  */
154
172
  export function freezeGraph({
@@ -157,9 +175,13 @@ export function freezeGraph({
157
175
  deploySha,
158
176
  editionsRootUrl,
159
177
  overrides = {},
178
+ memberPins,
160
179
  resolveHead,
161
180
  holdSource,
162
181
  }) {
182
+ if (memberPins !== undefined && Object.keys(overrides).length > 0) {
183
+ throw new Error('--member-pins and --overrides are exclusive: the member pins are the selected build records, never overridden');
184
+ }
163
185
  assertNoToolingOverride(overrides);
164
186
  assertOverrides(overrides, GRAPH_PHASE);
165
187
  const head = memoizedHeads(resolveHead);
@@ -171,6 +193,7 @@ export function freezeGraph({
171
193
  throw new Error(`override names "${key}", which is not a repository in the graph`);
172
194
  }
173
195
  }
196
+ if (memberPins !== undefined) {assertMemberPinsAreTheGraph(memberPins, members);}
174
197
  const checkedOut = [
175
198
  { key: dataPinKey(), sha: dataSha, flag: '--data-sha', what: 'the data root checkout' },
176
199
  { key: kernelPinKey(), sha: kernelSha, flag: '--kernel-sha', what: 'the kernel checkout' },
@@ -188,7 +211,7 @@ export function freezeGraph({
188
211
  }
189
212
  const pins = Object.fromEntries(checkedOut.map(({ key, sha }) => [key, sha]));
190
213
  for (const member of members) {
191
- pins[member.repo] = overrides[member.repo] ?? head({ name: member.repo, url: member.repositoryUrl });
214
+ pins[member.repo] = overrides[member.repo] ?? memberPins?.[member.repo] ?? head({ name: member.repo, url: member.repositoryUrl });
192
215
  }
193
216
  if (editionsRootUrl !== undefined) {
194
217
  const url = canonicalPublicHttpsRepositoryUrl(editionsRootUrl, '--editions-root-url');
@@ -0,0 +1,201 @@
1
+ // develop-git-bundles — the OCI bundle of a git-sourced biome, in a VENDOR DEVELOP edition only (XEMA-2456).
2
+ //
3
+ // A release and a customer edition never carry an OCI bundle on a git-sourced row: the manifest's `bundleSource` names
4
+ // an artifact the release lane builds, not one built from the pinned commit (resolve-distribution.mjs strips it, the
5
+ // release refuses it). A develop edition freezes a git source at its develop head F, and the source's own develop lane
6
+ // publishes the bundle the manifest names, signed keyless by its develop workflow. This module proves that bundle is
7
+ // the one F describes, and only then does the resolve keep it on the row, by DIGEST, with its develop bundle signer:
8
+ //
9
+ // 1. the bundle: the manifest's own `bundleSource.ociRef` at F (`<repository>:<version>`; the kernel holds the tag to
10
+ // the manifest version) resolved to its digest at the registry. The lock records `<repository>@sha256:<hex>`, never
11
+ // the tag, so the edition names immutable bytes;
12
+ // 2. the signer: the ONE `develop` row of the source's repository in the data root's trust policy
13
+ // (`bundleSignerRowFor`). cosign verifies the digest's signature under that row's identity, issuer, workflow
14
+ // repository and ref, and states the ONE commit P its certificate was issued at (`verifiedBundleSignerCommit`);
15
+ // `verifyBundleOnRegistry` then holds the signature AND the SLSA provenance to exactly the row and P. P is never
16
+ // read from bytes cosign has not verified;
17
+ // 3. the content binding of P to F. A version that is already published is never re-attested by the publisher
18
+ // (`xema biome publish` "already current"), so P is the commit that last published the version, and F may be a
19
+ // later commit of the same version. Two checks, refused on either, each naming P, F and the mismatching pair:
20
+ // (a) the kernel's `computeBiomeSourceManifestHash` of the bundle's own `xema-biome.json` equals the
21
+ // `sourceManifestHash` of the biome at F (the hash the seal and verify hold to the git source at F);
22
+ // (b) the git tree id of the biome directory at P equals its tree id at F (the catalogue's `sourceTreeIds`
23
+ // notion), so the bundle was built from the same biome source F holds.
24
+ // THE GAP, develop only: build inputs outside the biome directory (shared packages, the lockfile, the publisher's
25
+ // toolchain) are not bound. A change there between P and F reaches no check here. A release never relies on this:
26
+ // it carries a third-party bundle only through an `oci` source with a third-party pin, whose evidence is bound to
27
+ // the pinned commit itself.
28
+ //
29
+ // Every failure is a `DevelopGitBundleError` naming the biome; all subjects are verified before one is refused.
30
+
31
+ import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
32
+ import { RefusalError } from './collected-refusals.mjs';
33
+ import { EditionChannel } from './edition-channel.mjs';
34
+ import { editionWorkspaceConfig } from './edition-config.mjs';
35
+ import { loadPinnedDeclaredTrustPolicy } from './evidence-trust-pin.mjs';
36
+ import { sourceTreeIdAt } from './external-source-walk.mjs';
37
+ import { prepareTrustedCosignBinary } from './image-evidence.mjs';
38
+ import { BundleSignerRefusedError, DevelopGitBundleError } from './locked-bundle-signer.mjs';
39
+ import { createRegistryClientResolver } from './oci-image-source-evidence.mjs';
40
+ import { bundleDigestRef, fetchBundleManifest, verifiedBundleSignerCommit, verifyBundleOnRegistry } from './third-party-bundle-registry.mjs';
41
+ import { bundleSignerRowFor } from './third-party-source-evidence.mjs';
42
+ import { OCI_BUNDLE_SOURCE_KIND } from './third-party-subjects.mjs';
43
+
44
+ /** The key a verified bundle is looked up by: the source name and the biome id of its index entry. */
45
+ export const developGitBundleKey = (sourceName, biomeId) => `${sourceName}\u0000${biomeId}`;
46
+
47
+ /**
48
+ * The rows a develop resolve must verify a bundle for: each git-sourced biome of `lock` (a row with a `source`) whose
49
+ * index entry declares a biome-level OCI `bundleSource`.
50
+ *
51
+ * @returns {{biomeId: string, source: {name: string, url: string, commit: string}, entry: object}[]}
52
+ */
53
+ export function developGitBundleSubjects({ lock, index }) {
54
+ const entries = new Map(index.filter((entry) => entry.source !== undefined).map((entry) => [developGitBundleKey(entry.source, entry.id), entry]));
55
+ return lock.biomes.flatMap((biome) => {
56
+ if (biome.source === undefined) {return [];}
57
+ const entry = entries.get(developGitBundleKey(biome.source.name, biome.id));
58
+ return entry?.bundleSource?.kind === OCI_BUNDLE_SOURCE_KIND ? [{ biomeId: biome.id, source: biome.source, entry }] : [];
59
+ });
60
+ }
61
+
62
+ const subjectLabel = ({ biomeId, source }) => `biome "${biomeId}" (git source "${source.name}" at ${source.commit})`;
63
+
64
+ /** The biome directory relative to its source repository: the index path is `<source name>/<directory>`. */
65
+ function repositoryPathOf({ source, entry }) {
66
+ const prefix = `${source.name}/`;
67
+ if (!entry.path.startsWith(prefix)) {
68
+ throw new Error(`${subjectLabel({ biomeId: entry.id, source })}: index path ${JSON.stringify(entry.path)} is not under the source "${source.name}"`);
69
+ }
70
+ return entry.path.slice(prefix.length);
71
+ }
72
+
73
+ /** Verify every subject, then refuse them all at once: one refusal is rethrown unchanged, several are one error. */
74
+ async function verifyEach(subjects, verifyOne) {
75
+ const verified = new Map();
76
+ const refused = [];
77
+ for (const subject of subjects) {
78
+ try {
79
+ verified.set(developGitBundleKey(subject.source.name, subject.biomeId), await verifyOne(subject));
80
+ } catch (error) {
81
+ // A refusal, or a plain Error of the registry, cosign or git: this subject is refused. A defect (TypeError, ...) is not.
82
+ const refusal = error instanceof RefusalError || error?.constructor === Error;
83
+ if (!refusal) {throw error;}
84
+ refused.push(error instanceof DevelopGitBundleError ? error : new DevelopGitBundleError(`${subjectLabel(subject)}: ${error.message}`));
85
+ }
86
+ }
87
+ if (refused.length === 1) {throw refused[0];}
88
+ if (refused.length > 1) {throw new BundleSignerRefusedError(refused);}
89
+ return verified;
90
+ }
91
+
92
+ /**
93
+ * One subject, verified (steps 1 to 3 of this file's header). `context` is `{policy, policyRoot, schemas, trustedCosign}`.
94
+ *
95
+ * @returns {Promise<{bundleSource: object}>} the bundle source the lock row carries: the declared one, by digest
96
+ */
97
+ export async function verifyDevelopGitBundle({
98
+ subject,
99
+ checkout,
100
+ context,
101
+ clientFor,
102
+ signerCommit = verifiedBundleSignerCommit,
103
+ verifyOnRegistry = verifyBundleOnRegistry,
104
+ treeIdAt = sourceTreeIdAt,
105
+ env = process.env,
106
+ }) {
107
+ const { biomeId, source, entry } = subject;
108
+ const label = subjectLabel(subject);
109
+ if (checkout === undefined) {
110
+ throw new Error(`${label}: the source walk holds no checkout of "${source.name}"`);
111
+ }
112
+ const repository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(source.url, `${label} source url`), `${label} source url`);
113
+ const row = bundleSignerRowFor({ policy: context.policy, repository, channel: EditionChannel.Develop, schemas: context.schemas, label: `${label} develop bundle signer` });
114
+
115
+ // 1 + 2: the digest the manifest's tag serves; the commit P cosign verified the develop signature at; signature and
116
+ // provenance held to the row at P.
117
+ const ociRef = await bundleDigestRef({ clientFor, ociRef: entry.bundleSource.ociRef });
118
+ const cosign = { trustedCosign: context.trustedCosign, policy: context.policy, policyRoot: context.policyRoot, row, ociRef, env };
119
+ const attestedCommit = signerCommit(cosign);
120
+ verifyOnRegistry({ ...cosign, workflowSha: attestedCommit });
121
+
122
+ // 3: the content binding of P to F (see the header for the gap it leaves: build inputs outside the biome directory).
123
+ const binding = `the bundle ${ociRef} attested at ${attestedCommit} (P) against the source at ${source.commit} (F)`;
124
+ const { manifest } = await fetchBundleManifest({ clientFor, ociRef });
125
+ if (manifest?.xema?.id !== biomeId) {
126
+ throw new Error(`${label}: ${ociRef} declares xema.id ${JSON.stringify(manifest?.xema?.id)}, not "${biomeId}"`);
127
+ }
128
+ const bundleManifestHash = await context.schemas.computeBiomeSourceManifestHash(manifest);
129
+ if (bundleManifestHash !== entry.sourceManifestHash) {
130
+ throw new DevelopGitBundleError(
131
+ `${label}: ${binding}: the bundle's xema-biome.json hashes to sourceManifestHash ${bundleManifestHash}, but the manifest at F hashes to ${entry.sourceManifestHash}`,
132
+ );
133
+ }
134
+ const path = repositoryPathOf(subject);
135
+ const treeAtAttested = treeIdAt({ checkout, commit: attestedCommit, path, env });
136
+ const treeAtFrozen = treeIdAt({ checkout, commit: source.commit, path, env });
137
+ if (treeAtAttested !== treeAtFrozen) {
138
+ throw new DevelopGitBundleError(
139
+ `${label}: ${binding}: the biome directory ${path} is tree ${treeAtAttested} at P but tree ${treeAtFrozen} at F; publish a new version of the biome from F`,
140
+ );
141
+ }
142
+ return { bundleSource: { ...entry.bundleSource, ociRef } };
143
+ }
144
+
145
+ /**
146
+ * The verifier a vendor develop resolve hands `prepareDistributionWithIndex` (`inputs.developGitBundles`). Nothing is
147
+ * read (no trust policy, no cosign) unless a subject exists.
148
+ *
149
+ * @param {object} options
150
+ * @param {string|undefined} options.cosignBinary the cosign the trust policy pins by digest (`--cosign`)
151
+ * @param {string} options.dataRoot
152
+ */
153
+ export function createDevelopGitBundleVerifier({ cosignBinary, dataRoot, env = process.env, clientFor, treeIdAt }) {
154
+ return Object.freeze({
155
+ async verify({ subjects, checkouts, schemas }) {
156
+ if (subjects.length === 0) {return new Map();}
157
+ if (cosignBinary === undefined) {
158
+ throw new DevelopGitBundleError(
159
+ `${subjects.map(subjectLabel).join(', ')} declare an OCI bundle; verifying it needs a cosign binary (--cosign), and none was given`,
160
+ );
161
+ }
162
+ const policy = loadPinnedDeclaredTrustPolicy({ dataRoot, declaration: editionWorkspaceConfig().evidenceTrustPolicy, schemas });
163
+ const trustedCosign = prepareTrustedCosignBinary({ cosignBinary, policy });
164
+ const context = { policy, policyRoot: dataRoot, schemas, trustedCosign };
165
+ const registry = clientFor ?? createRegistryClientResolver({ dataRoot });
166
+ try {
167
+ return await verifyEach(subjects, (subject) =>
168
+ verifyDevelopGitBundle({ subject, checkout: checkouts.get(subject.source.name), context, clientFor: registry, env, ...(treeIdAt === undefined ? {} : { treeIdAt }) }),
169
+ );
170
+ } finally {
171
+ trustedCosign.dispose();
172
+ }
173
+ },
174
+ });
175
+ }
176
+
177
+ /**
178
+ * The OFFLINE verifier of a candidate re-seal: the bundle the pinned develop edition already verified for the SAME
179
+ * source commit, carried forward unchanged, with the signer it was verified under (`pinnedBundleSigner`): the resolve
180
+ * refuses the row when the current trust policy selects any other signer (a rotated develop row). A subject at another commit, or one the pinned edition carried no bundle
181
+ * for, is refused: verifying a new bundle reads the registry, which an offline re-seal does not.
182
+ *
183
+ * @param {object} pinnedLock the pinned edition's parsed distribution lock
184
+ */
185
+ export function carriedDevelopGitBundles(pinnedLock) {
186
+ return Object.freeze({
187
+ async verify({ subjects }) {
188
+ return verifyEach(subjects, async (subject) => {
189
+ const row = pinnedLock.biomes.find(
190
+ (biome) => biome.id === subject.biomeId && biome.source?.name === subject.source.name && biome.source.commit === subject.source.commit,
191
+ );
192
+ if (row?.bundleSource?.kind !== OCI_BUNDLE_SOURCE_KIND || row.bundleSigner === undefined) {
193
+ throw new DevelopGitBundleError(
194
+ `${subjectLabel(subject)}: the pinned edition carries no verified bundle at this commit, and an offline re-seal cannot verify one; resolve the develop edition instead`,
195
+ );
196
+ }
197
+ return { bundleSource: row.bundleSource, pinnedBundleSigner: row.bundleSigner };
198
+ });
199
+ },
200
+ });
201
+ }
@@ -13,14 +13,16 @@ import { compareStrings } from './lib.mjs';
13
13
 
14
14
  /**
15
15
  * The develop graph: `{ repo, repositoryUrl }` per declared first-party
16
- * repository, sorted by repo handle.
16
+ * repository, sorted by repo handle, plus `currentResolution` (its ticket) on a
17
+ * member the data root declares one for (develop-current-resolution.mjs).
17
18
  *
18
- * @returns {readonly { repo: string, repositoryUrl: string }[]}
19
+ * @returns {readonly { repo: string, repositoryUrl: string, currentResolution?: string }[]}
19
20
  */
20
21
  export function developGraphMembers() {
21
22
  return Object.freeze(
22
23
  Object.entries(firstPartySources('the develop graph'))
23
- .map(([repo, { repositoryUrl }]) => Object.freeze({ repo, repositoryUrl }))
24
+ .map(([repo, { repositoryUrl, currentResolution }]) =>
25
+ Object.freeze({ repo, repositoryUrl, ...(currentResolution === undefined ? {} : { currentResolution }) }))
24
26
  .sort((a, b) => compareStrings(a.repo, b.repo)),
25
27
  );
26
28
  }
@@ -8,7 +8,8 @@
8
8
  // hash-verified manifest — as soon as that subject's hash exists, in parallel
9
9
  // with the hashing of the rest, and `settle()` refuses with ONE error naming
10
10
  // every missing coordinate. It verifies existence only: the platform check, the
11
- // digest decision and the size bounds stay in the seal, which still runs.
11
+ // digest decision and the size bounds stay in the seal, which still runs. `settle()` also answers the digest each
12
+ // present coordinate resolved to (`<kind>:<id>` -> digest), for a caller that records it (develop image-record).
12
13
 
13
14
  import { RefusalError } from './collected-refusals.mjs';
14
15
  import { developImageRef } from './develop-image-coordinate.mjs';
@@ -39,7 +40,7 @@ export function notBuiltYetError(distributionId, missingSubjects, channel = 'dev
39
40
  /**
40
41
  * @param {object} request
41
42
  * @param {string} request.distributionId the edition (named in the refusal)
42
- * @returns {{ observe(subject: object): void, settle(): Promise<void> }}
43
+ * @returns {{ observe(subject: object): void, settle(): Promise<Map<string, string>> }}
43
44
  * `observe` takes a subject with `repo`, `sourceCommit`, `imageRef` (release ref) and `contentHash`
44
45
  * and starts its probe; `settle` waits for all of them and throws when any is absent or unreadable.
45
46
  */
@@ -72,9 +73,9 @@ export function createDevelopImageProbe({
72
73
  const startedAt = Date.now();
73
74
  const { repository } = splitImageRef(subject.imageRef);
74
75
  try {
75
- await clientFor(subject).getManifestByTag(repository, subject.contentHash);
76
+ const { digest } = await clientFor(subject).getManifestByTag(repository, subject.contentHash);
76
77
  progress?.(`[probe] ${subject.kind}:${subject.id} present ${Date.now() - startedAt}ms`);
77
- return { subject };
78
+ return { subject, digest };
78
79
  } catch (error) {
79
80
  progress?.(`[probe] ${subject.kind}:${subject.id} ${isNotPublishedYet(error) ? 'ABSENT' : 'FAILED'} ${Date.now() - startedAt}ms`);
80
81
  return { subject, error };
@@ -113,6 +114,7 @@ export function createDevelopImageProbe({
113
114
  if (aborting) {throw aborting.error;}
114
115
  const missing = settled.filter(({ error }) => error !== undefined).map(({ subject }) => subject);
115
116
  if (missing.length > 0) {throw notBuiltYetError(distributionId, missing);}
117
+ return new Map(settled.map(({ subject, digest }) => [`${subject.kind}:${subject.id}`, digest]));
116
118
  },
117
119
  };
118
120
  }