@xemahq/xema 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/edition/verify-image-evidence.bundle.mjs +1996 -1012
- package/dist/edition/verify-image-evidence.bundle.provenance.json +584 -500
- package/dist/runtime/lock-loader.d.ts +2 -1
- package/dist/runtime/lock-loader.d.ts.map +1 -1
- package/dist/runtime/lock-loader.js +15 -5
- package/dist/runtime/lock-loader.js.map +1 -1
- package/edition/README.md +8 -3
- package/edition/biome-content.mjs +4 -4
- package/edition/candidate-edition.mjs +8 -3
- package/edition/check-committed-image-evidence.mjs +44 -15
- package/edition/customer-edition-verbs.mjs +15 -4
- package/edition/develop-current-resolution.mjs +29 -0
- package/edition/develop-edition-resolve.mjs +18 -10
- package/edition/develop-edition.mjs +30 -9
- package/edition/develop-freeze.mjs +24 -1
- package/edition/develop-git-bundles.mjs +201 -0
- package/edition/develop-graph.mjs +5 -3
- package/edition/develop-image-probe.mjs +6 -4
- package/edition/develop-image-record.mjs +194 -0
- package/edition/develop-record-ancestry.mjs +23 -5
- package/edition/develop-select-records.mjs +29 -21
- package/edition/develop-selection-line.mjs +30 -0
- package/edition/develop-selection-pins.mjs +30 -0
- package/edition/dist-check.mjs +45 -9
- package/edition/distribution-evidence-set.mjs +14 -9
- package/edition/edition-image-seal.mjs +2 -1
- package/edition/edition-protocol.mjs +6 -20
- package/edition/external-source-walk.mjs +37 -15
- package/edition/image-evidence.mjs +6 -2
- package/edition/image-lock-service-rows.mjs +4 -4
- package/edition/image-subject-identity.mjs +24 -0
- package/edition/images-from-record.mjs +46 -110
- package/edition/lib.mjs +24 -26
- package/edition/locked-bundle-signer.mjs +52 -8
- package/edition/resolve-customer-edition.mjs +1 -1
- package/edition/resolve-distribution.mjs +84 -21
- package/edition/resolve-edition.mjs +2 -1
- package/edition/resolve-images.mjs +224 -12
- package/edition/seal-image-digests.mjs +24 -13
- package/edition/third-party-bundle-registry.mjs +73 -17
- package/edition/third-party-source-evidence.mjs +39 -14
- package/edition/third-party-source-writer.mjs +2 -18
- package/edition/trust-policy-arming.mjs +17 -6
- package/edition/verify-image-platforms.mjs +2 -0
- package/edition/workspace-sections.mjs +11 -1
- package/package.json +3 -3
|
@@ -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. --
|
|
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 }]) =>
|
|
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<
|
|
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
|
}
|