@xemahq/xema 0.58.0 → 0.59.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/commands/edition.js +1 -1
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +90 -1
- package/dist/edition/verify-image-evidence.bundle.provenance.json +66 -66
- package/edition/README.md +7 -1
- package/edition/biome-content.mjs +25 -16
- package/edition/catalogue-lock-field.mjs +41 -0
- package/edition/customer-edition-verbs.mjs +93 -12
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-catalogue-free-resolve.mjs +280 -0
- package/edition/develop-edition.mjs +43 -1
- package/edition/develop-follow.mjs +491 -0
- package/edition/develop-record-ancestry.mjs +21 -16
- package/edition/develop-selection-pins.mjs +11 -5
- package/edition/distribution-evidence-set.mjs +2 -0
- package/edition/edition-artifact.mjs +44 -1
- package/edition/edition-constants.mjs +22 -2
- package/edition/edition-contract-parse.mjs +42 -0
- package/edition/edition-contract.json +18 -1
- package/edition/image-evidence.mjs +71 -0
- package/edition/pin-source.mjs +3 -1
- package/edition/record-refused.mjs +3 -2
- package/edition/resolve-customer-edition.mjs +37 -8
- package/edition/resolve-edition.mjs +3 -0
- package/edition/resolve-images.mjs +13 -4
- package/edition/third-party-bundle-registry.mjs +54 -17
- package/package.json +3 -3
package/edition/README.md
CHANGED
|
@@ -83,6 +83,8 @@ xema edition develop <freeze-sources|resolve|target|check-existing> ...
|
|
|
83
83
|
# develop tag = sha256 of { cli, engine, pins, sourceImages }; target and check-existing take --engine-image, and a customer's --source-image
|
|
84
84
|
xema edition develop coverage <id> --pins <file> --cosign <abs path> [--json] # read-only forecast (policy held to its pin, this CLI's own verifier code): is each develop image producer-signed? exit 0 covered, 1 not, 2 error
|
|
85
85
|
xema edition develop select-records --branch develop --registry <root> --cosign <abs path> --out <file> [--at <ISO-8601 UTC>] [--published-pins <file>] # newest signed build record per graph member (the newest COMMIT on develop, every record verified; --published-pins keeps a published commit the records are not ahead of, part 07 passes it) -> { pins, snapshot, behind, images, currentResolution }, written once (a member whose firstPartySources entry declares `currentResolution: "<ticket>"`, xema-docs until XEMA-2373, is skipped with no registry read, listed in currentResolution and printed as `xema-docs: current resolution (leaves the edition, XEMA-2373)`) (behind = { <repo>: { head, commits } }: develop's head and the commits the pinned one trails it by; stdout gets one `<short name>: record <commit>, <N> commits behind HEAD` line per member, always `commits`, short name = the repo's last path segment and unambiguous; images[] entries carry `member`); any bad record refuses the run (needs dataRootRepositoryUrl in edition-workspace.json; anyone who can delete registry tags can remove newer records and this then pins the newest remaining valid one, and the guard is part 07's: deploy-latest's owner refuses a snapshot that goes backwards)
|
|
86
|
+
xema edition develop follow --definition-at <owner>/<repo>,<path>,<dataRoot> --data-sha <sha> --kernel-sha <sha> --deploy-sha <upper bound> --engine-repository <registry>/<path> --registry <root> --cosign <abs path> (--first | --current-pins <file>) --out <file> --seals-out <dir> --previous-seal-signer <identity> (--previous-lock <lock> --previous-lock-sha256 <hex> | --first-seal) # the COMPLETE frozen pins a dev deployment's follow policy selects, written once: members by select-records' newest-commit rule over their signed build records with the current pins as the published pins (a diverged member is refused by name), pinned as freeze-graph --member-pins pins them; the deploy root the newest first-parent commit at or below --deploy-sha whose engine sha-<12 hex> is published (unpublished skipped, none within the walk refused by name); the data/kernel/deploy roots by freeze-graph's rules and, from current pins, never behind or beside them (ancestry in each root's own checkout, which needs full history); declared sources at their develop heads, newer-wins against the current pin by the same ancestry rule. The data root checkout is the definition (its declared repository, clean at --data-sha, data root and distribution directory as named); the kernel checkout is at --kernel-sha. Beside the pins, --seals-out (written once) freezes both previous seals: the offering's newest signed develop edition's images lock, read once under --previous-seal-signer, and the customer's own previous lock (or first seal), passed through; seals.json names each file and its sha256. Prints the summary; --first says it is a first follow
|
|
87
|
+
xema edition develop resolve --definition-at <owner>/<repo>,<path>,<dataRoot> --offering <owner>/<repo>,<path>,<dataRoot> --pins <file> --out <dir> --registry <root> --engine-repository <registry>/<path> --engine-signer <identity> --cosign <abs path> --offering-previous-lock <lock> --offering-previous-lock-sha256 <hex> (--previous-lock <lock> --previous-lock-sha256 <hex> | --first-seal) [--source-image ...] # catalogue-free customer resolve: --pins is follow's frozen pins plus "definition-repository" (the definition's commit); the vendor step resolves the offering at the vendor pins under the offering's id and previous seal (the frozen offering lock), the customer step reads the definition at its commit and seals its own composed rows against the customer's previous lock. Reads no heads, pushes nothing; the same frozen inputs give the same bytes
|
|
86
88
|
xema edition develop image-record <id> --selection <file> --pins <file> --out <file> # the edition's image build record (what `resolve --images-from` reads) from a select-records selection: subjects are the checked-out snapshot's first-party image subjects; a current-resolution member's (xema-docs) is probed and goes in `images`, every other takes the selection's image of its develop repository (selected commit must equal the snapshot's) and goes in `satellites`; platforms read by digest; any gap refuses and writes nothing; written once
|
|
87
89
|
xema edition develop verify-image-record --image <name>@sha256:<digest> --producer <owner/repo> --registry <root> --cosign <abs path> # is the digest bound to the producer by an authenticated develop build record (shared verify core with select-records). Records are read newest run first (listBuildRecordRuns sorts by compareBuildRecordRuns: run, then attempt, highest first), a verified record of a workflow other than the producer's `.github/workflows/ci.yaml` is skipped, and the answer is the newest ci.yaml record listing the exact image -> { image, digest, producer, commit }. `commit` is the commit of the newest verified ci.yaml record (by run number, then attempt) that lists the digest; it can be an older commit when a later run targeted it, and for an image the producer reused by content hash it is NOT the commit that built it. This is safe because the digest is what binds the bytes. Refuses when no ci.yaml record lists the image, the record names another producer, or cosign refuses the identity
|
|
88
90
|
xema edition develop check-overrides --overrides <json> --data-root <dir> # data root only
|
|
@@ -184,6 +186,8 @@ A write of `images.lock.json` (`resolve-images`, `xema edition develop resolve`)
|
|
|
184
186
|
|
|
185
187
|
- `--previous-seal-signer <certificate identity>` (develop resolve, vendor form): the CLI lists the edition's own repository (`<registry root>/develop-editions/xema-edition-<id>`), reads every snapshot tag, and takes the newest edition by its `org.opencontainers.image.created` annotation (inside the signed manifest). An edition with no signature was never published and is passed over; the newest signed one must verify, offline through the pinned cosign, for exactly that identity and the GitHub OIDC issuer, or the resolve is refused (it never steps back to an older edition). Its images.lock layer is fetched by digest. The run must be under the per-edition concurrency group, so the edition read is the one the previous run pushed.
|
|
186
188
|
- `--previous-lock <lock> --previous-lock-sha256 <hex>`: the same layer, taken by the caller; the CLI refuses unless the bytes hash to the digest.
|
|
189
|
+
|
|
190
|
+
A customer develop resolve (`--definition`, or the catalogue-free form) takes `--previous-lock`/`--previous-lock-sha256` or `--first-seal` (never `--previous-seal-signer`), under the customer's id, and it feeds only the customer's own composed rows; the catalogue rows are the catalogue's. In the catalogue-free form the vendor rows carry forward from the offering's own seal, `--offering-previous-lock`/`--offering-previous-lock-sha256` (frozen by `develop follow`), under the offering's id. A release customer resolve takes none.
|
|
187
191
|
- `--first-seal`: generation 1, only for a distribution that has never had an edition. The CLI never infers it: a 404 for the repository (a registry may hide a repository from a credential) and a repository with no signed edition (retention) are refused, naming `--first-seal`.
|
|
188
192
|
|
|
189
193
|
The in-repo resolve and pin/unpin take the committed `images.lock.json` at the target path as the previous lock (first seal when absent).
|
|
@@ -243,13 +247,15 @@ A held commit is authorized, never trusted, and nothing overrides it: the commit
|
|
|
243
247
|
A customer edition is resolved against a catalogue pinned in `catalogue.pin.json` beside the definition file. The catalogue is the ceiling: it supplies availability, pins and digests and never adds a component. It also names the kernel: `kernel` is `{name, version, integrity}` (`@xemahq/contracts`, an exact `x.y.z`, the npm `sha512-` integrity of that tarball), and a catalogue without it is refused. Its `ceiling.storePublisherTrust` is the offering lock's Store publishers (or `null`); a definition selects among them with `storePublisherAnchorKeys`, the resolve copies the selected entries into the customer lock, and the kernel's `customerLockCeilingIssues` refuses, at resolve, seal and verify, an entry the definition does not select, a key the catalogue does not vouch for, or an entry that differs from the catalogue's.
|
|
244
248
|
|
|
245
249
|
```bash
|
|
246
|
-
xema edition resolve --definition <file> --channel
|
|
250
|
+
xema edition resolve --definition <file> --channel release \
|
|
247
251
|
--data-root <dir> --sources-root <dir> --cache-dir <dir> --out <dir> \
|
|
248
252
|
--source-image <source>=<registry>/<prefix> [--catalogue-username <u> --catalogue-token-env <VAR>]
|
|
249
253
|
xema edition release resolve --definition <file> --cosign <abs path> ... # release: E4 evidence verified before the catalogue is read
|
|
250
254
|
xema edition resolve-digests <id> --definition <file> --data-root <dir> --editions-root <dir> --sources-root <dir> --cache-dir <dir>
|
|
251
255
|
```
|
|
252
256
|
|
|
257
|
+
`--channel develop` is refused on this verb: it takes no previous seal, so a customer's own composed rows would restart at generation 1. Resolve a develop customer edition with `xema edition develop resolve --definition <file> (--previous-lock <file> --previous-lock-sha256 <hex> | --first-seal) ...`.
|
|
258
|
+
|
|
253
259
|
To use a customer's own registry, copy the catalogue artifact and vendor images by digest with `oras cp`, pin the mirrored catalogue in `catalogue.pin.json`, and declare `imageRegistryMirror: {complete: true, map: [{source, target}]}` in the definition.
|
|
254
260
|
|
|
255
261
|
A customer verb takes no `--kernel` (it is refused) and needs no kernel checkout and no credential for the kernel. After it fetches its catalogue it downloads `@xemahq/contracts@<catalogue.kernel.version>` from registry.npmjs.org into `<--cache-dir>/kernel-npm/`, holds the tarball to the catalogue's npm integrity before extracting it, and proves the package is the catalogue's contract authority (final protocol identity carrying `contractAuthority.contentDigest`) before it loads any schema.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { execFileSync } from 'node:child_process';
|
|
20
20
|
import { relative, sep } from 'node:path';
|
|
21
21
|
|
|
22
|
+
import { CatalogueLockField, catalogueLockFieldOf } from './catalogue-lock-field.mjs';
|
|
22
23
|
import { assertCustomerLockWithinCatalogueCeiling } from './catalogue.mjs';
|
|
23
24
|
import { readGitJsonAtCommit } from './lib.mjs';
|
|
24
25
|
import { assertLockBundleSigners } from './locked-bundle-signer.mjs';
|
|
@@ -198,21 +199,28 @@ async function catalogueFacts({ schemas, biome, imageLock, catalogue }) {
|
|
|
198
199
|
|
|
199
200
|
/**
|
|
200
201
|
* Customer mode: the lock must say which catalogue produced its hashes, and it must be
|
|
201
|
-
* the catalogue supplied
|
|
202
|
-
*
|
|
202
|
+
* the catalogue supplied, in the same field: `catalogueDigest` (a published catalogue artifact)
|
|
203
|
+
* or `catalogueContentDigest` (the catalogue bytes of a catalogue-free resolve), exactly one supplied
|
|
204
|
+
* and the lock carrying that one only. A lock without it, with another digest, or naming the catalogue
|
|
205
|
+
* in the other field, was not resolved against this catalogue, so its hashes cannot be checked on
|
|
206
|
+
* this catalogue's authority.
|
|
203
207
|
*/
|
|
204
|
-
function assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, storePublisherAnchorKeys }) {
|
|
205
|
-
|
|
206
|
-
|
|
208
|
+
function assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys }) {
|
|
209
|
+
const { key, digest } = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what: `customer mode for catalogue "${catalogue.offeringId}"`, ErrorType: BiomeContentHashError });
|
|
210
|
+
const other = key === CatalogueLockField.Artifact ? CatalogueLockField.Content : CatalogueLockField.Artifact;
|
|
211
|
+
if (lock[other] !== undefined) {
|
|
212
|
+
throw new BiomeContentHashError(
|
|
213
|
+
`distribution "${lock.distributionId}": the lock carries ${other}, but the supplied catalogue is named by ${key} ${digest}`,
|
|
214
|
+
);
|
|
207
215
|
}
|
|
208
|
-
if (lock
|
|
216
|
+
if (lock[key] === undefined) {
|
|
209
217
|
throw new BiomeContentHashError(
|
|
210
|
-
`distribution "${lock.distributionId}": the lock carries no
|
|
218
|
+
`distribution "${lock.distributionId}": the lock carries no ${key}, so it was not resolved against a catalogue; supplied catalogue ${digest}`,
|
|
211
219
|
);
|
|
212
220
|
}
|
|
213
|
-
if (lock
|
|
221
|
+
if (lock[key] !== digest) {
|
|
214
222
|
throw new BiomeContentHashError(
|
|
215
|
-
`distribution "${lock.distributionId}": the lock was resolved against catalogue ${lock
|
|
223
|
+
`distribution "${lock.distributionId}": the lock was resolved against catalogue ${lock[key]}, not the supplied ${digest} (${key})`,
|
|
216
224
|
);
|
|
217
225
|
}
|
|
218
226
|
// The lock is within the catalogue's ceiling: the kernel's one check, its Store publishers against the definition's selection.
|
|
@@ -229,13 +237,14 @@ function assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, s
|
|
|
229
237
|
* @param {(repo: string) => {dir: string, base: string}} request.locate repo handle -> checkout
|
|
230
238
|
* @param {(repo: string) => string} request.vendoredCommit the vendored-source pin of a first-party repo
|
|
231
239
|
* @param {object} [request.catalogue] customer mode: the digest-pinned catalogue vendor biomes are taken from
|
|
232
|
-
* @param {string} [request.catalogueDigest] customer mode: the digest of
|
|
240
|
+
* @param {string} [request.catalogueDigest] customer mode, a published catalogue: the digest of its artifact; the lock must carry the same one
|
|
241
|
+
* @param {string} [request.catalogueContentDigest] customer mode, catalogue-free: the digest of the catalogue bytes; exclusive with catalogueDigest, the lock must carry the same one
|
|
233
242
|
* @param {readonly string[]} [request.storePublisherAnchorKeys] customer mode: the definition's Store publisher selection (absent = none)
|
|
234
243
|
* @param {Map<string, string>} [request.contentCommits] a candidate edition's recorded override: repo -> the commit its first-party biomes' content is read at
|
|
235
244
|
* @returns {Promise<Map<string, string>>} biome id -> BiomeContentHash
|
|
236
245
|
*/
|
|
237
|
-
export async function computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits }) {
|
|
238
|
-
if (catalogue !== undefined) {assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, storePublisherAnchorKeys });}
|
|
246
|
+
export async function computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits }) {
|
|
247
|
+
if (catalogue !== undefined) {assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys });}
|
|
239
248
|
const hashes = new Map();
|
|
240
249
|
for (const biome of lock.biomes) {
|
|
241
250
|
const facts =
|
|
@@ -291,9 +300,9 @@ export async function biomeSourceTreeIds({ schemas, lock, imageLock, locate, ven
|
|
|
291
300
|
}
|
|
292
301
|
|
|
293
302
|
/** Seal: the unsealed lock + the recomputed hashes -> the kernel's `sealDistributionLock`. */
|
|
294
|
-
export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById, bundleSigners, contentCommits, gitSourcedBundle }) {
|
|
303
|
+
export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, sourceFactsById, bundleSigners, contentCommits, gitSourcedBundle }) {
|
|
295
304
|
assertLockBundleSigners({ lock: unsealed, selection: bundleSigners, schemas, gitSourcedBundle });
|
|
296
|
-
const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits });
|
|
305
|
+
const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits });
|
|
297
306
|
admitDistributionLock(schemas, unsealed);
|
|
298
307
|
return schemas.sealDistributionLock(unsealed, hashes);
|
|
299
308
|
}
|
|
@@ -302,8 +311,8 @@ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredC
|
|
|
302
311
|
* Verify: recompute every biome's hash and REFUSE a mismatch, naming the biome.
|
|
303
312
|
* The lock's own field is never trusted; a missing or extra biome is a mismatch.
|
|
304
313
|
*/
|
|
305
|
-
export async function verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners, contentCommits, gitSourcedBundle }) {
|
|
306
|
-
const expected = await computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, contentCommits });
|
|
314
|
+
export async function verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, bundleSigners, contentCommits, gitSourcedBundle }) {
|
|
315
|
+
const expected = await computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, contentCommits });
|
|
307
316
|
assertLockBundleSigners({ lock, selection: bundleSigners, schemas, gitSourcedBundle });
|
|
308
317
|
const mismatched = lock.biomes
|
|
309
318
|
.filter((biome) => biome.biomeContentHash !== expected.get(biome.id))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// catalogue-lock-field — how a customer edition's distribution lock names the catalogue it was resolved against
|
|
2
|
+
// (kernel edition protocol v37, DistributionLock): EXACTLY ONE of
|
|
3
|
+
//
|
|
4
|
+
// catalogueDigest the digest of a PUBLISHED catalogue artifact (the catalogue pin form);
|
|
5
|
+
// catalogueContentDigest sha256 of the exact catalogue.json bytes a catalogue-free resolve's vendor step wrote
|
|
6
|
+
// (the catalogue-free pin form: no catalogue artifact exists).
|
|
7
|
+
//
|
|
8
|
+
// The kernel refuses a lock carrying both; this CLI refuses a request naming both or neither, and a field that is
|
|
9
|
+
// not its pin form's, before anything is resolved or sealed.
|
|
10
|
+
|
|
11
|
+
/** The two lock fields, by what they digest. */
|
|
12
|
+
export const CatalogueLockField = Object.freeze({ Artifact: 'catalogueDigest', Content: 'catalogueContentDigest' });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `{ key, digest }` of the one catalogue field `request` names (CatalogueLockField). Throws `new ErrorType(message)`
|
|
16
|
+
* when it names both or neither; `what` names the caller in the message.
|
|
17
|
+
*/
|
|
18
|
+
export function catalogueLockFieldOf(request, { what, ErrorType = Error }) {
|
|
19
|
+
const named = Object.values(CatalogueLockField).filter((key) => request?.[key] !== undefined);
|
|
20
|
+
if (named.length !== 1) {
|
|
21
|
+
throw new ErrorType(`${what} names ${named.length === 0 ? 'neither' : 'both'} ${CatalogueLockField.Artifact} (a published catalogue) ${named.length === 0 ? 'nor' : 'and'} ${CatalogueLockField.Content} (a catalogue-free resolve): exactly one`);
|
|
22
|
+
}
|
|
23
|
+
const [key] = named;
|
|
24
|
+
return { key, digest: request[key] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The lock field of a customer edition's pin form: the catalogue-free form records the content digest, the catalogue form the artifact digest. */
|
|
28
|
+
export const catalogueLockFieldOfForm = (catalogueFree) => (catalogueFree ? CatalogueLockField.Content : CatalogueLockField.Artifact);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one catalogue field of a customer edition's lock, `{ key, digest }`: exactly one named, and the one its pin form
|
|
32
|
+
* records (`catalogueFree`: the catalogue-free form). Throws `new ErrorType(message)` otherwise.
|
|
33
|
+
*/
|
|
34
|
+
export function customerLockCatalogueField({ catalogueFree, catalogueDigest, catalogueContentDigest }, { what, ErrorType = Error }) {
|
|
35
|
+
const field = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what, ErrorType });
|
|
36
|
+
const formField = catalogueLockFieldOfForm(catalogueFree);
|
|
37
|
+
if (field.key !== formField) {
|
|
38
|
+
throw new ErrorType(`a ${catalogueFree ? 'catalogue-free' : 'catalogue-form'} customer edition names its catalogue by ${formField}, not ${field.key}`);
|
|
39
|
+
}
|
|
40
|
+
return field;
|
|
41
|
+
}
|
|
@@ -18,15 +18,16 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|
|
18
18
|
import { sealLock } from './biome-content.mjs';
|
|
19
19
|
import { assertCatalogueConsumerArmed } from './catalogue-evidence.mjs';
|
|
20
20
|
import { fetchCatalogue, parseCatalogueRef } from './catalogue-fetch.mjs';
|
|
21
|
+
import { customerLockCatalogueField } from './catalogue-lock-field.mjs';
|
|
21
22
|
import { readCataloguePin } from './catalogue-pin.mjs';
|
|
22
|
-
import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, COSIGN_FLAG, DEFINITION_FLAG, SOURCE_IMAGE_FLAG } from './customer-flags.mjs';
|
|
23
|
+
import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, COSIGN_FLAG, DEFINITION_AT_FLAG, DEFINITION_FLAG, OFFERING_FLAG, OFFERING_PREVIOUS_LOCK_FLAG, OFFERING_PREVIOUS_LOCK_SHA256_FLAG, SOURCE_IMAGE_FLAG } from './customer-flags.mjs';
|
|
23
24
|
import { freezeCustomerSources, lsRemoteRef } from './develop-freeze.mjs';
|
|
24
25
|
import { customerDevelopGitBundleVerifier } from './develop-git-bundles.mjs';
|
|
25
26
|
import { isDevelopImageRef } from './develop-image-coordinate.mjs';
|
|
26
27
|
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
27
28
|
import { LOCAL_WORKFLOW, assertEngineImage, assertExistingArtifactMatches, snapshotEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
28
29
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
29
|
-
import { CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY, EditionChannel, EditionKind, EditionLockFile } from './edition-constants.mjs';
|
|
30
|
+
import { CATALOGUE_PIN_KEY, CustomerPinForm, DEFINITION_PIN_KEY, EditionChannel, EditionKind, EditionLockFile, customerPinFormAdmitted } from './edition-constants.mjs';
|
|
30
31
|
import { channelImageRef, sealImageSubjects } from './edition-image-seal.mjs';
|
|
31
32
|
import { WorkspaceRoot } from './edition-workspace.mjs';
|
|
32
33
|
import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
|
|
@@ -39,7 +40,7 @@ import { isSourcePinKey, sortedSnapshotPins, sourcePinKey } from './pin-source.m
|
|
|
39
40
|
import { PREVIOUS_SEAL_SIGNER_FLAG } from './previous-seal-registry.mjs';
|
|
40
41
|
import { assertSealedImageLock } from './release-edition-artifact.mjs';
|
|
41
42
|
import { assertSourceImagesCoverDefinition, parseSourceImages, resolveCustomerEdition } from './resolve-customer-edition.mjs';
|
|
42
|
-
import { externalSourceTrees, imageRefForSubject, sourceTreeLocator } from './resolve-images.mjs';
|
|
43
|
+
import { externalSourceTrees, imageRefForSubject, resolvePreviousSeal, sourceTreeLocator } from './resolve-images.mjs';
|
|
43
44
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
44
45
|
|
|
45
46
|
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
@@ -66,7 +67,11 @@ export function customerDefinitionDigest(rawDefinition) {
|
|
|
66
67
|
* catalogue artifact and `definition` = the digest of the definition (customerDefinitionDigest).
|
|
67
68
|
* A `catalogue` or `definition` entry in the file must equal that digest.
|
|
68
69
|
*/
|
|
69
|
-
export function customerEditionPins({ channel, pins, declaredSourceNames, catalogueDigest, definitionDigest }) {
|
|
70
|
+
export function customerEditionPins({ channel, pins, declaredSourceNames, catalogueDigest, definitionDigest, vendorPins }) {
|
|
71
|
+
const catalogueFree = vendorPins !== undefined;
|
|
72
|
+
if (catalogueFree && !customerPinFormAdmitted(CustomerPinForm.CatalogueFree, EditionKind.Resolved, channel)) {
|
|
73
|
+
throw new CustomerEditionPinsError(`a ${channel} customer edition is never resolved catalogue-free`);
|
|
74
|
+
}
|
|
70
75
|
if (typeof definitionDigest !== 'string') {throw new CustomerEditionPinsError('a customer edition pins the digest of its definition');}
|
|
71
76
|
if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
|
|
72
77
|
throw new CustomerEditionPinsError('--pins must be an object mapping sources/<name> -> 40-hex commit');
|
|
@@ -74,6 +79,7 @@ export function customerEditionPins({ channel, pins, declaredSourceNames, catalo
|
|
|
74
79
|
const own = {};
|
|
75
80
|
for (const [key, value] of Object.entries(pins)) {
|
|
76
81
|
if (key === CATALOGUE_PIN_KEY) {
|
|
82
|
+
if (catalogueFree) {throw new CustomerEditionPinsError(`--pins names "${CATALOGUE_PIN_KEY}", but a catalogue-free edition pins no catalogue: the catalogue's digest goes into the distribution lock only`);}
|
|
77
83
|
if (value !== catalogueDigest) {
|
|
78
84
|
throw new CustomerEditionPinsError(`--pins "${CATALOGUE_PIN_KEY}" is ${JSON.stringify(value)}, but the pinned ${channel} catalogue is ${catalogueDigest}`);
|
|
79
85
|
}
|
|
@@ -100,7 +106,13 @@ export function customerEditionPins({ channel, pins, declaredSourceNames, catalo
|
|
|
100
106
|
for (const key of Object.keys(own)) {
|
|
101
107
|
if (!declared.has(key)) {throw new CustomerEditionPinsError(`--pins names "${key}", but the definition declares no such source`);}
|
|
102
108
|
}
|
|
103
|
-
return sortedSnapshotPins({ ...own, [CATALOGUE_PIN_KEY]: catalogueDigest, [DEFINITION_PIN_KEY]: definitionDigest });
|
|
109
|
+
if (!catalogueFree) {return sortedSnapshotPins({ ...own, [CATALOGUE_PIN_KEY]: catalogueDigest, [DEFINITION_PIN_KEY]: definitionDigest });}
|
|
110
|
+
for (const key of Object.keys(vendorPins)) {
|
|
111
|
+
if (key === CATALOGUE_PIN_KEY || key === DEFINITION_PIN_KEY || key in own) {
|
|
112
|
+
throw new CustomerEditionPinsError(`the vendor pins of a catalogue-free edition name "${key}", which is the customer's own`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return sortedSnapshotPins({ ...vendorPins, ...own, [DEFINITION_PIN_KEY]: definitionDigest });
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/** The source names a raw definition declares (the pins are checked before the definition is composed). */
|
|
@@ -109,16 +121,22 @@ export function declaredSourceNamesOf(rawDefinition) {
|
|
|
109
121
|
return Array.isArray(sources) ? sources.map((source) => source?.name) : [];
|
|
110
122
|
}
|
|
111
123
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Resolve and seal the customer edition of `channel`; returns the four locks and the frozen provenance pins. The pin form
|
|
126
|
+
* decides the lock's catalogue field: catalogue-free (`vendorPins`) records `catalogueContentDigest`, the catalogue form
|
|
127
|
+
* `catalogueDigest`. `previousSeal(id)` answers the customer's own previous seal (resolvePreviousSeal; absent: a first seal).
|
|
128
|
+
*/
|
|
129
|
+
export async function resolveCustomerChannelEdition({ channel, rawDefinition, catalogue, catalogueDigest, catalogueContentDigest, pins, vendorPins, previousSeal, sourceImages, env = process.env, sealOptions = {}, adoptKernel, materialize, developGitBundles }) {
|
|
130
|
+
const catalogueField = customerLockCatalogueField({ catalogueFree: vendorPins !== undefined, catalogueDigest, catalogueContentDigest }, { what: `a ${channel} customer resolve`, ErrorType: CustomerEditionPinsError });
|
|
131
|
+
const frozen = customerEditionPins({ channel, pins, declaredSourceNames: declaredSourceNamesOf(rawDefinition), catalogueDigest, definitionDigest: customerDefinitionDigest(rawDefinition), ...(vendorPins === undefined ? {} : { vendorPins }) });
|
|
115
132
|
const resolved = await resolveCustomerEdition({
|
|
116
133
|
rawDefinition,
|
|
117
134
|
catalogue,
|
|
118
135
|
channel,
|
|
119
136
|
sourceImages,
|
|
120
137
|
pins: frozen,
|
|
121
|
-
|
|
138
|
+
[catalogueField.key]: catalogueField.digest,
|
|
139
|
+
...(previousSeal === undefined ? {} : { previousSeal }),
|
|
122
140
|
...(adoptKernel === undefined ? {} : { adoptKernel }),
|
|
123
141
|
...(materialize === undefined ? {} : { materialize }),
|
|
124
142
|
...(developGitBundles === undefined ? {} : { developGitBundles }),
|
|
@@ -136,7 +154,7 @@ export async function resolveCustomerChannelEdition({ channel, rawDefinition, ca
|
|
|
136
154
|
throw new Error(`customer biome source "${repo}" is first-party to no checkout; vendor biomes are taken from the catalogue`);
|
|
137
155
|
},
|
|
138
156
|
catalogue,
|
|
139
|
-
|
|
157
|
+
[catalogueField.key]: catalogueField.digest,
|
|
140
158
|
storePublisherAnchorKeys: resolved.storePublisherAnchorKeys,
|
|
141
159
|
// A row the develop verifier kept carries its bundle and develop signer; the seal admits exactly those.
|
|
142
160
|
gitSourcedBundle: developGitBundles === undefined ? GitSourcedBundle.Refused : GitSourcedBundle.DevelopVerified,
|
|
@@ -203,7 +221,18 @@ const COMMANDS = Object.freeze({
|
|
|
203
221
|
values: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG, IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, PREVIOUS_SEAL_SIGNER_FLAG],
|
|
204
222
|
switches: ['--local', FIRST_SEAL_FLAG],
|
|
205
223
|
required: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
|
|
206
|
-
customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG, ENGINE_IMAGE_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG,
|
|
224
|
+
customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG, ENGINE_IMAGE_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG, PREVIOUS_SEAL_SIGNER_FLAG], cosign: true, developCosign: true },
|
|
225
|
+
// The customer's own previous seal (customerPreviousSeal): --previous-lock with --previous-lock-sha256, or --first-seal, on develop only.
|
|
226
|
+
customerPreviousSeal: [PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, FIRST_SEAL_FLAG],
|
|
227
|
+
// The catalogue-free customer form (develop only, develop-catalogue-free-resolve.mjs): the vendor offering is resolved in
|
|
228
|
+
// the same call, so it names both definitions as triples, the vendor step's engine flags and the offering's frozen previous
|
|
229
|
+
// seal; --previous-lock/--first-seal are the customer's own.
|
|
230
|
+
catalogueFree: {
|
|
231
|
+
markers: [DEFINITION_AT_FLAG, OFFERING_FLAG],
|
|
232
|
+
values: [DEFINITION_AT_FLAG, OFFERING_FLAG, SOURCE_IMAGE_FLAG, OFFERING_PREVIOUS_LOCK_FLAG, OFFERING_PREVIOUS_LOCK_SHA256_FLAG],
|
|
233
|
+
required: [DEFINITION_AT_FLAG, OFFERING_FLAG, '--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG, OFFERING_PREVIOUS_LOCK_FLAG, OFFERING_PREVIOUS_LOCK_SHA256_FLAG],
|
|
234
|
+
refused: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, ENGINE_IMAGE_FLAG, IMAGES_FROM_FLAG, PREVIOUS_SEAL_SIGNER_FLAG],
|
|
235
|
+
},
|
|
207
236
|
// developCosign: on develop, --cosign verifies the git-sourced OCI bundles under the data root's pinned policy (develop-git-bundles.mjs).
|
|
208
237
|
// A vendor resolve finds its engine itself; the old flag is refused by name, never read as a customer flag.
|
|
209
238
|
removedFromVendor: { [ENGINE_IMAGE_FLAG]: `use ${ENGINE_REPOSITORY_FLAG} and ${ENGINE_SIGNER_FLAG}` },
|
|
@@ -240,6 +269,14 @@ const COMMANDS = Object.freeze({
|
|
|
240
269
|
switches: ['--json'],
|
|
241
270
|
required: ['--pins', '--cosign'],
|
|
242
271
|
},
|
|
272
|
+
follow: {
|
|
273
|
+
positional: false,
|
|
274
|
+
values: [DEFINITION_AT_FLAG, '--data-sha', '--kernel-sha', '--deploy-sha', ENGINE_REPOSITORY_FLAG, '--registry', COSIGN_FLAG, '--current-pins', '--out', '--seals-out', PREVIOUS_SEAL_SIGNER_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG],
|
|
275
|
+
// exactly one of --first and --current-pins (develop-follow.mjs followStartOf); the customer's own previous seal is
|
|
276
|
+
// exactly one of --previous-lock with --previous-lock-sha256, or --first-seal (customerPreviousLockInput)
|
|
277
|
+
switches: ['--first', FIRST_SEAL_FLAG],
|
|
278
|
+
required: [DEFINITION_AT_FLAG, '--data-sha', '--kernel-sha', '--deploy-sha', ENGINE_REPOSITORY_FLAG, '--registry', COSIGN_FLAG, '--out', '--seals-out', PREVIOUS_SEAL_SIGNER_FLAG],
|
|
279
|
+
},
|
|
243
280
|
'select-records': {
|
|
244
281
|
positional: false,
|
|
245
282
|
values: ['--branch', '--registry', '--cosign', '--out', '--at', '--published-pins'],
|
|
@@ -281,6 +318,21 @@ function commandsOf(channel) {
|
|
|
281
318
|
throw new Error(`unknown edition channel ${JSON.stringify(channel)}`);
|
|
282
319
|
}
|
|
283
320
|
|
|
321
|
+
/** The catalogue-free customer form of a develop verb: no positional id (it is the definition's), no catalogue flags. */
|
|
322
|
+
function catalogueFreeArgs(command, spec, options, positional) {
|
|
323
|
+
for (const refused of spec.refused) {
|
|
324
|
+
if (options[refused] !== undefined) {
|
|
325
|
+
throw new Error(`a catalogue-free ${command} takes no ${refused}: it names the definition and the offering as ${DEFINITION_AT_FLAG} and ${OFFERING_FLAG}, pins no catalogue, and its vendor step finds its engine itself`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (positional.length !== 0) {throw new Error(`a catalogue-free ${command} takes no <distribution-id>: the id is the definition's`);}
|
|
329
|
+
for (const required of spec.required) {
|
|
330
|
+
if (options[required] === undefined) {throw new Error(`a catalogue-free ${command} requires ${required}`);}
|
|
331
|
+
}
|
|
332
|
+
if (!isAbsolute(options[COSIGN_FLAG])) {throw new Error(`${COSIGN_FLAG} must be an absolute path, got ${JSON.stringify(options[COSIGN_FLAG])}`);}
|
|
333
|
+
return { command, id: undefined, options, customer: false, catalogueFree: true };
|
|
334
|
+
}
|
|
335
|
+
|
|
284
336
|
/** Parse `<command> [<id>] --flag value ...` of `channel` against the closed vocabulary of the command. */
|
|
285
337
|
export function parseEditionVerbArgs(channel, argv) {
|
|
286
338
|
const commands = commandsOf(channel);
|
|
@@ -291,13 +343,14 @@ export function parseEditionVerbArgs(channel, argv) {
|
|
|
291
343
|
}
|
|
292
344
|
// The flags only the customer form takes: a flag the vendor form takes too (`--cosign` of a develop resolve) does not make it one.
|
|
293
345
|
const customerValues = (spec.customer?.values ?? []).filter((flag) => !spec.values.includes(flag));
|
|
346
|
+
const catalogueFreeValues = channel === EditionChannel.Develop ? (spec.catalogueFree?.values ?? []) : [];
|
|
294
347
|
const options = {};
|
|
295
348
|
const positional = [];
|
|
296
349
|
for (let i = 0; i < rest.length; i += 1) {
|
|
297
350
|
const arg = rest[i];
|
|
298
351
|
if (spec.switches.includes(arg)) {
|
|
299
352
|
options[arg] = true;
|
|
300
|
-
} else if (spec.values.includes(arg) || customerValues.includes(arg)) {
|
|
353
|
+
} else if (spec.values.includes(arg) || customerValues.includes(arg) || catalogueFreeValues.includes(arg)) {
|
|
301
354
|
const value = rest[i + 1];
|
|
302
355
|
if (value === undefined || (value.startsWith('--') && value.length > 2)) {
|
|
303
356
|
throw new Error(`${arg} requires a value`);
|
|
@@ -316,6 +369,12 @@ export function parseEditionVerbArgs(channel, argv) {
|
|
|
316
369
|
positional.push(arg);
|
|
317
370
|
}
|
|
318
371
|
}
|
|
372
|
+
if (catalogueFreeValues.length > 0 && spec.catalogueFree.markers.some((flag) => options[flag] !== undefined)) {
|
|
373
|
+
return catalogueFreeArgs(command, spec.catalogueFree, options, positional);
|
|
374
|
+
}
|
|
375
|
+
for (const flag of catalogueFreeValues.filter((value) => !customerValues.includes(value) && !spec.values.includes(value))) {
|
|
376
|
+
if (options[flag] !== undefined) {throw new Error(`${flag} belongs to the catalogue-free form of ${command} (${spec.catalogueFree.markers.join(' and ')})`);}
|
|
377
|
+
}
|
|
319
378
|
const customer = spec.customer !== undefined && customerValues.some((flag) => options[flag] !== undefined);
|
|
320
379
|
// `--cosign` of a customer form verifies a release catalogue, and on a develop resolve the git-sourced OCI bundles
|
|
321
380
|
// (`developCosign`); a vendor verb (develop `coverage`, `resolve`, `engine`) takes its own `--cosign`.
|
|
@@ -337,6 +396,10 @@ export function parseEditionVerbArgs(channel, argv) {
|
|
|
337
396
|
for (const refused of spec.customer.refused) {
|
|
338
397
|
if (options[refused] !== undefined) {throw new Error(`a customer ${command} takes no ${refused}`);}
|
|
339
398
|
}
|
|
399
|
+
// The customer's own previous seal is a develop resolve's (customerPreviousSeal); a release customer resolve takes none.
|
|
400
|
+
for (const flag of spec.customerPreviousSeal ?? []) {
|
|
401
|
+
if (channel !== EditionChannel.Develop && options[flag] !== undefined) {throw new Error(`a ${channel} customer ${command} takes no ${flag}`);}
|
|
402
|
+
}
|
|
340
403
|
if (positional.length !== 0) {throw new Error(`a customer ${command} takes no <distribution-id>: the id is the definition's`);}
|
|
341
404
|
if (spec.customer.cosign === true && channel === EditionChannel.Release && options[COSIGN_FLAG] === undefined) {
|
|
342
405
|
throw new Error(`a release ${command} requires ${COSIGN_FLAG} <absolute path of the cosign binary>`);
|
|
@@ -394,6 +457,23 @@ async function fetchPinnedCatalogue(channel, options, env, fetch = fetchCatalogu
|
|
|
394
457
|
});
|
|
395
458
|
}
|
|
396
459
|
|
|
460
|
+
/**
|
|
461
|
+
* The customer's own previous seal of a develop resolve, as `previousSeal(id)` (resolveCustomerChannelEdition): exactly
|
|
462
|
+
* one of `--previous-lock <path> --previous-lock-sha256 <hex>` (the customer edition's own last images lock, its bytes
|
|
463
|
+
* held to the sha256 and its id to the edition's) or `--first-seal`. No signer form: a resolve reads no "newest", so the
|
|
464
|
+
* same frozen inputs resolve the same bytes. A release customer resolve takes none (refused at the parse).
|
|
465
|
+
*/
|
|
466
|
+
export function customerPreviousSeal(channel, options) {
|
|
467
|
+
if (channel !== EditionChannel.Develop) {return undefined;}
|
|
468
|
+
return (distributionId) =>
|
|
469
|
+
resolvePreviousSeal({
|
|
470
|
+
distributionId,
|
|
471
|
+
previousLockPath: options[PREVIOUS_LOCK_FLAG],
|
|
472
|
+
previousLockSha256: options[PREVIOUS_LOCK_SHA256_FLAG],
|
|
473
|
+
firstSeal: Boolean(options[FIRST_SEAL_FLAG]),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
397
477
|
/** A customer resolve: the artifact (descriptor and four locks, no catalogue: it is not an offering) goes to --out. */
|
|
398
478
|
async function resolveCustomer(channel, options, env) {
|
|
399
479
|
const { catalogue, digest } = await fetchPinnedCatalogue(channel, options, env);
|
|
@@ -407,6 +487,7 @@ async function resolveCustomer(channel, options, env) {
|
|
|
407
487
|
catalogue,
|
|
408
488
|
catalogueDigest: digest,
|
|
409
489
|
pins: readStrictJsonFile(options['--pins']),
|
|
490
|
+
previousSeal: customerPreviousSeal(channel, options),
|
|
410
491
|
sourceImages,
|
|
411
492
|
env,
|
|
412
493
|
// Develop, under a declared customer policy: the git-sourced OCI bundles are verified with --cosign (none: refused when a subject exists).
|
|
@@ -6,3 +6,10 @@ export const CATALOGUE_USERNAME_FLAG = '--catalogue-username';
|
|
|
6
6
|
export const CATALOGUE_TOKEN_ENV_FLAG = '--catalogue-token-env';
|
|
7
7
|
/** The absolute path of the cosign binary that verifies a release catalogue's E4 evidence (release only). */
|
|
8
8
|
export const COSIGN_FLAG = '--cosign';
|
|
9
|
+
/** A definition named as `<owner>/<repo>,<path>,<dataRoot>` at a pinned commit (`develop follow`); `--definition` is a file. */
|
|
10
|
+
export const DEFINITION_AT_FLAG = '--definition-at';
|
|
11
|
+
/** The vendor offering a catalogue-free resolve resolves in the same call, as `<owner>/<repo>,<path>,<dataRoot>` of the vendor data checkout. */
|
|
12
|
+
export const OFFERING_FLAG = '--offering';
|
|
13
|
+
/** The offering's own previous seal a catalogue-free resolve's vendor step carries forward: the images lock `develop follow` froze, and its sha256. */
|
|
14
|
+
export const OFFERING_PREVIOUS_LOCK_FLAG = '--offering-previous-lock';
|
|
15
|
+
export const OFFERING_PREVIOUS_LOCK_SHA256_FLAG = '--offering-previous-lock-sha256';
|