@xemahq/xema 0.57.1 → 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 +109 -17
- 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-git-bundles.mjs +61 -7
- 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/repin-evidence-trust.mjs +2 -1
- package/edition/resolve-customer-edition.mjs +49 -9
- package/edition/resolve-distribution.mjs +2 -1
- package/edition/resolve-edition.mjs +3 -0
- package/edition/resolve-images.mjs +13 -4
- package/edition/third-party-bundle-registry.mjs +54 -15
- package/package.json +3 -3
|
@@ -25,27 +25,31 @@ import { withSourceGitEnv } from './git-source-auth.mjs';
|
|
|
25
25
|
import { RecordRefused } from './record-refused.mjs';
|
|
26
26
|
|
|
27
27
|
const DEVELOP_REF = 'refs/heads/develop';
|
|
28
|
+
/** The tag a published pin carries in a RecordCommitNotOnDevelopError. */
|
|
29
|
+
export const PUBLISHED_PIN_TAG = 'published pin';
|
|
28
30
|
// One external call to a git remote; a fetch that exceeds it fails naming the member, never a verdict.
|
|
29
31
|
const GIT_CALL_TIMEOUT_MS = 300_000;
|
|
30
32
|
const GIT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
31
33
|
|
|
32
34
|
export class IncomparableRecordCommitsError extends RecordRefused {
|
|
33
|
-
constructor(repo, first, second) {
|
|
34
|
-
super(repo, `record commits ${first.commit} (${first.tags.join(', ')}) and ${second.commit} (${second.tags.join(', ')}) are not ancestor-related on develop
|
|
35
|
+
constructor(repo, first, second, context) {
|
|
36
|
+
super(repo, `record commits ${first.commit} (${first.tags.join(', ')}) and ${second.commit} (${second.tags.join(', ')}) are not ancestor-related on develop`, context);
|
|
35
37
|
this.name = 'IncomparableRecordCommitsError';
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export class RecordCommitNotOnDevelopError extends RecordRefused {
|
|
40
|
-
constructor(repo, commit, tags) {
|
|
41
|
-
super(repo, `record commit ${commit} (${tags.join(', ')}) is not in the fetched develop history
|
|
42
|
+
constructor(repo, commit, tags, context) {
|
|
43
|
+
super(repo, `record commit ${commit} (${tags.join(', ')}) is not in the fetched develop history`, context);
|
|
42
44
|
this.name = 'RecordCommitNotOnDevelopError';
|
|
45
|
+
this.commit = commit;
|
|
46
|
+
this.tags = tags;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export class DivergedFromPublishedError extends RecordRefused {
|
|
47
|
-
constructor(repo, selected, published) {
|
|
48
|
-
super(repo, `the newest record commit ${selected} and the published commit ${published} are not ancestor-related on develop
|
|
51
|
+
constructor(repo, selected, published, context) {
|
|
52
|
+
super(repo, `the newest record commit ${selected} and the published commit ${published} are not ancestor-related on develop`, context);
|
|
49
53
|
this.name = 'DivergedFromPublishedError';
|
|
50
54
|
}
|
|
51
55
|
}
|
|
@@ -75,10 +79,11 @@ export function defaultRunGit(args, { cwd, env }) {
|
|
|
75
79
|
* @param {object} request.workspace the workspace config the source hold reads
|
|
76
80
|
* @param {object} request.env
|
|
77
81
|
* @param {Function} [request.runGit] (args, {cwd, env}) => {status, stdout, stderr}; replaced by tests
|
|
82
|
+
* @param {string} [request.context] the verb its errors name (`select-records` by default)
|
|
78
83
|
* @returns {{commit: string, head: string, behind: number}} the selected commit, develop's head in the same fetch, and
|
|
79
84
|
* how many commits the selected one is behind that head (0 when it is the head)
|
|
80
85
|
*/
|
|
81
|
-
export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit }) {
|
|
86
|
+
export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit, context = 'select-records' }) {
|
|
82
87
|
const byCommit = new Map();
|
|
83
88
|
for (const { commit, tag } of candidates) {
|
|
84
89
|
byCommit.set(commit, [...(byCommit.get(commit) ?? []), tag]);
|
|
@@ -89,28 +94,28 @@ export function newestCommitOnDevelop({ member, candidates, published, workspace
|
|
|
89
94
|
return withSourceGitEnv({ name: member.repo, url: member.repositoryUrl }, { workspace, env }, (gitEnv) => {
|
|
90
95
|
const git = (args, extraEnv = {}) => runGit(args, { cwd: scratch, env: { ...gitEnv, ...extraEnv } });
|
|
91
96
|
const init = git(['init', '--bare', '--quiet']);
|
|
92
|
-
if (init.status !== 0) {throw new Error(
|
|
97
|
+
if (init.status !== 0) {throw new Error(`${context}: ${member.repo}: git init failed: ${init.stderr.trim()}`);}
|
|
93
98
|
const fetched = git(['fetch', '--quiet', '--filter=blob:none', '--no-tags', member.repositoryUrl, `+${DEVELOP_REF}:${DEVELOP_REF}`]);
|
|
94
99
|
if (fetched.status !== 0) {
|
|
95
|
-
throw new Error(
|
|
100
|
+
throw new Error(`${context}: ${member.repo}: git fetch of develop from ${member.repositoryUrl} failed (exit ${fetched.status}): ${fetched.stderr.trim()}`);
|
|
96
101
|
}
|
|
97
102
|
// 0 = ancestor (or equal), 1 = not; anything else is git failing, never a verdict
|
|
98
103
|
const isAncestor = (older, newer) => {
|
|
99
104
|
const result = git(['merge-base', '--is-ancestor', older, newer]);
|
|
100
105
|
if (result.status === 0) {return true;}
|
|
101
106
|
if (result.status === 1) {return false;}
|
|
102
|
-
throw new Error(
|
|
107
|
+
throw new Error(`${context}: ${member.repo}: git merge-base ${older} ${newer} failed (exit ${result.status}): ${result.stderr.trim()}`);
|
|
103
108
|
};
|
|
104
109
|
const assertOnDevelop = (commit, tags) => {
|
|
105
110
|
// no lazy fetch: a commit the develop fetch did not bring is not on develop
|
|
106
111
|
const known = git(['cat-file', '-e', `${commit}^{commit}`], { GIT_NO_LAZY_FETCH: '1' });
|
|
107
|
-
if (known.status !== 0 || !isAncestor(commit, DEVELOP_REF)) {throw new RecordCommitNotOnDevelopError(member.repo, commit, tags);}
|
|
112
|
+
if (known.status !== 0 || !isAncestor(commit, DEVELOP_REF)) {throw new RecordCommitNotOnDevelopError(member.repo, commit, tags, context);}
|
|
108
113
|
};
|
|
109
114
|
for (const { commit, tags } of commits) {assertOnDevelop(commit, tags);}
|
|
110
115
|
let selected = commits[0];
|
|
111
116
|
for (const other of commits.slice(1)) {
|
|
112
117
|
if (isAncestor(other.commit, selected.commit)) {continue;}
|
|
113
|
-
if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other);}
|
|
118
|
+
if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other, context);}
|
|
114
119
|
selected = other;
|
|
115
120
|
}
|
|
116
121
|
// develop's head and the chosen commit's distance from it, read in the same scratch the choice was made in
|
|
@@ -118,20 +123,20 @@ export function newestCommitOnDevelop({ member, candidates, published, workspace
|
|
|
118
123
|
const headResult = git(['rev-parse', DEVELOP_REF]);
|
|
119
124
|
const head = headResult.stdout.trim();
|
|
120
125
|
if (headResult.status !== 0 || !/^[0-9a-f]{40}$/.test(head)) {
|
|
121
|
-
throw new Error(
|
|
126
|
+
throw new Error(`${context}: ${member.repo}: git rev-parse ${DEVELOP_REF} failed (exit ${headResult.status}): ${headResult.stderr.trim() || head}`);
|
|
122
127
|
}
|
|
123
128
|
const counted = git(['rev-list', '--count', `${commit}..${DEVELOP_REF}`]);
|
|
124
129
|
const behind = Number(counted.stdout.trim());
|
|
125
130
|
if (counted.status !== 0 || !/^\d+$/.test(counted.stdout.trim())) {
|
|
126
|
-
throw new Error(
|
|
131
|
+
throw new Error(`${context}: ${member.repo}: git rev-list --count ${commit}..${DEVELOP_REF} failed (exit ${counted.status}): ${counted.stderr.trim() || counted.stdout.trim()}`);
|
|
127
132
|
}
|
|
128
133
|
return { commit, head, behind };
|
|
129
134
|
};
|
|
130
135
|
if (published === undefined) {return withDistance(selected.commit);}
|
|
131
|
-
assertOnDevelop(published, [
|
|
136
|
+
assertOnDevelop(published, [PUBLISHED_PIN_TAG]);
|
|
132
137
|
if (isAncestor(selected.commit, published)) {return withDistance(published);} // equal, or the records are behind it
|
|
133
138
|
if (isAncestor(published, selected.commit)) {return withDistance(selected.commit);}
|
|
134
|
-
throw new DivergedFromPublishedError(member.repo, selected.commit, published);
|
|
139
|
+
throw new DivergedFromPublishedError(member.repo, selected.commit, published, context);
|
|
135
140
|
});
|
|
136
141
|
} finally {
|
|
137
142
|
rmSync(scratch, { recursive: true, force: true });
|
|
@@ -17,14 +17,20 @@ export function parseSelectionPins(document, path, flag, members) {
|
|
|
17
17
|
if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
|
|
18
18
|
throw new Error(`${flag} ${path} has no "pins" object`);
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
22
|
-
throw new Error(`${flag} ${path}: pin ${JSON.stringify(repo)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
20
|
+
assertCommitPins(pins, `${flag} ${path}`);
|
|
25
21
|
if (members !== undefined) {
|
|
26
22
|
const problem = currentResolutionProblem(document.currentResolution, members);
|
|
27
23
|
if (problem !== null) {throw new Error(`${flag} ${path}: ${problem}`);}
|
|
28
24
|
}
|
|
29
25
|
return pins;
|
|
30
26
|
}
|
|
27
|
+
|
|
28
|
+
/** A pin map's every value is a 40-hex commit (a selection's pins, follow's --current-pins); returns it. `label` names the input. */
|
|
29
|
+
export function assertCommitPins(pins, label) {
|
|
30
|
+
for (const [key, commit] of Object.entries(pins)) {
|
|
31
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
32
|
+
throw new Error(`${label}: pin ${JSON.stringify(key)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return pins;
|
|
36
|
+
}
|
|
@@ -829,6 +829,8 @@ export function buildImageDeliveryEntitlementStatement({
|
|
|
829
829
|
lockDigest: schemas.distributionLockDigest(distributionLock),
|
|
830
830
|
// Present-and-nullable: null states the lock carries no catalogue; the lock digest binds the lock either way.
|
|
831
831
|
catalogueDigest: distributionLock.catalogueDigest ?? null,
|
|
832
|
+
// The field the lock carries, never both: a catalogue-free lock's catalogueContentDigest.
|
|
833
|
+
...(distributionLock.catalogueContentDigest === undefined ? {} : { catalogueContentDigest: distributionLock.catalogueContentDigest }),
|
|
832
834
|
distributionStatementDigest: completenessStatementDigest,
|
|
833
835
|
mode,
|
|
834
836
|
targetRegistry,
|
|
@@ -19,6 +19,7 @@ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
|
19
19
|
import { EDITION_CHART_SET_FILE, assertRosteredChartSetLock } from './edition-chart-set.mjs';
|
|
20
20
|
import {
|
|
21
21
|
CATALOGUE_PIN_KEY,
|
|
22
|
+
CustomerPinForm,
|
|
22
23
|
DEFINITION_PIN_KEY,
|
|
23
24
|
DIGEST_PIN_KEYS,
|
|
24
25
|
EDITION_DESCRIPTOR_FILE,
|
|
@@ -31,11 +32,13 @@ import {
|
|
|
31
32
|
EditionLockFile,
|
|
32
33
|
SEALED_EDITION_CHANNEL,
|
|
33
34
|
SEAL_OF_PATTERN,
|
|
35
|
+
VENDOR_MEMBER_PIN_PATTERN,
|
|
34
36
|
admittedEvidenceTrustStates,
|
|
37
|
+
customerPinFormAdmitted,
|
|
35
38
|
} from './edition-constants.mjs';
|
|
36
39
|
import { dataRoot, editionsRoot } from './edition-workspace.mjs';
|
|
37
40
|
import { serializeJson, sha256Hex } from './lib.mjs';
|
|
38
|
-
import { sortedSnapshotPins } from './pin-source.mjs';
|
|
41
|
+
import { isSourcePinKey, sortedSnapshotPins } from './pin-source.mjs';
|
|
39
42
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
40
43
|
|
|
41
44
|
/** Which GitHub Actions run produced the artifact; recorded in its descriptor. */
|
|
@@ -119,6 +122,45 @@ const exactKeys = (value, keys, where) => {
|
|
|
119
122
|
}
|
|
120
123
|
};
|
|
121
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The closed customer pin rule (edition-contract.json provenancePins.customerForms). A descriptor whose pins carry the
|
|
127
|
+
* definition pin is a customer edition, and it carries exactly one form: the CATALOGUE form (the catalogue pin, the
|
|
128
|
+
* definition pin and sources/<name> commits, nothing else), or the CATALOGUE-FREE form (no catalogue pin, at least one
|
|
129
|
+
* vendor member pin), each only on the kinds and channels the contract admits it on. A descriptor without the definition
|
|
130
|
+
* pin is not a customer edition and the rule does not apply to it.
|
|
131
|
+
*/
|
|
132
|
+
export function assertCustomerPinForm(kind, channel, pins, distributionId) {
|
|
133
|
+
if (!(DEFINITION_PIN_KEY in pins)) {
|
|
134
|
+
// only a customer edition records a vendor side: every other edition carries neither form's marker
|
|
135
|
+
if (CATALOGUE_PIN_KEY in pins) {
|
|
136
|
+
throw new Error(`${EDITION_DESCRIPTOR_FILE}: the ${kind} ${channel} edition "${distributionId}" carries the catalogue pin without the definition pin: only a customer edition (the definition pin) records the catalogue it was resolved against`);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const where = `${EDITION_DESCRIPTOR_FILE}: the customer ${kind} ${channel} edition "${distributionId}"`;
|
|
141
|
+
if (CATALOGUE_PIN_KEY in pins) {
|
|
142
|
+
if (!customerPinFormAdmitted(CustomerPinForm.Catalogue, kind, channel)) {
|
|
143
|
+
throw new Error(`${where} carries the catalogue pin, but the contract admits the catalogue form on no ${kind} ${channel} edition`);
|
|
144
|
+
}
|
|
145
|
+
const stray = Object.keys(pins).filter((key) => key !== CATALOGUE_PIN_KEY && key !== DEFINITION_PIN_KEY && !isSourcePinKey(key));
|
|
146
|
+
if (stray.length > 0) {
|
|
147
|
+
throw new Error(`${where} carries the catalogue pin and also ${stray.map((key) => JSON.stringify(key)).join(', ')}: the catalogue form pins only the catalogue, the definition and sources/<name>`);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!customerPinFormAdmitted(CustomerPinForm.CatalogueFree, kind, channel)) {
|
|
152
|
+
throw new Error(`${where} carries no catalogue pin: only a ${catalogueFreeAdmissions()} customer edition may be resolved catalogue-free`);
|
|
153
|
+
}
|
|
154
|
+
if (!Object.keys(pins).some((key) => VENDOR_MEMBER_PIN_PATTERN.test(key))) {
|
|
155
|
+
throw new Error(`${where} carries neither the catalogue pin nor a vendor member pin: a catalogue-free edition records the vendor member pins it was resolved from`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const catalogueFreeAdmissions = () =>
|
|
160
|
+
Object.values(EditionKind)
|
|
161
|
+
.flatMap((kind) => Object.values(EditionChannel).filter((channel) => customerPinFormAdmitted(CustomerPinForm.CatalogueFree, kind, channel)).map((channel) => `${kind} ${channel}`))
|
|
162
|
+
.join(' or ');
|
|
163
|
+
|
|
122
164
|
/**
|
|
123
165
|
* The strict parse of a descriptor: exact keys, the closed kind enum (no default, never inferred), `sealOf` exactly on a
|
|
124
166
|
* sealed edition, closed vocabularies, the evidence trust state the contract admits for the kind on the channel, an engine
|
|
@@ -164,6 +206,7 @@ export function parseEditionDescriptor(value) {
|
|
|
164
206
|
);
|
|
165
207
|
}
|
|
166
208
|
}
|
|
209
|
+
assertCustomerPinForm(value.kind, value.provenance.channel, pins, value.distributionId);
|
|
167
210
|
exactKeys(workflow, ['repository', 'runId'], `${EDITION_DESCRIPTOR_FILE} provenance.workflow`);
|
|
168
211
|
if (!workflow.repository || !workflow.runId || typeof workflow.repository !== 'string' || typeof workflow.runId !== 'string') {
|
|
169
212
|
throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.workflow is {repository, runId}, both non-empty strings`);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { EditionChannel } from './edition-channel.mjs';
|
|
8
8
|
import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
|
|
9
|
-
import { parseEditionContract } from './edition-contract-parse.mjs';
|
|
9
|
+
import { CustomerPinForm, parseEditionContract } from './edition-contract-parse.mjs';
|
|
10
10
|
import editionContract from './edition-contract.json' with { type: 'json' };
|
|
11
11
|
import { EditionKind, EvidenceTrustState } from './edition-kind.mjs';
|
|
12
12
|
|
|
@@ -40,7 +40,7 @@ export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
|
|
|
40
40
|
Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
|
|
41
41
|
);
|
|
42
42
|
|
|
43
|
-
export { EditionChannel, EditionKind, EvidenceTrustState };
|
|
43
|
+
export { CustomerPinForm, EditionChannel, EditionKind, EvidenceTrustState };
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The evidence trust states an edition of `kind` on `channel` may record (contract `evidenceTrust.statesByKind`):
|
|
@@ -72,9 +72,29 @@ if (!(DEFINITION_PIN_KEY in EDITION_CONTRACT.provenancePins)) {
|
|
|
72
72
|
throw new Error('edition-contract.json provenancePins does not declare the definition pin');
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The commit of the customer definition repository a catalogue-free customer edition's definition was read at: a pin of
|
|
77
|
+
* the catalogue-free form only (provenancePins.customerForms["catalogue-free"].pins names it), a 40-hex commit.
|
|
78
|
+
*/
|
|
79
|
+
export const DEFINITION_REPOSITORY_PIN_KEY = 'definition-repository';
|
|
80
|
+
if (!EDITION_CONTRACT.provenancePins.customerForms['catalogue-free'].pins.includes(`"${DEFINITION_REPOSITORY_PIN_KEY}"`)) {
|
|
81
|
+
throw new Error(`edition-contract.json provenancePins.customerForms["catalogue-free"].pins does not name the "${DEFINITION_REPOSITORY_PIN_KEY}" pin`);
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
/** The provenance pins that are sha256 digests, not commits. */
|
|
76
85
|
export const DIGEST_PIN_KEYS = Object.freeze([CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY]);
|
|
77
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The key of a vendor member (develop graph repository) pin, `repos/<x>` or `submodules/<x>`: the one statement is the
|
|
89
|
+
* contract's (provenancePins.customerForms.vendorMemberPinPattern); the snapshot pin namespaces read it from here.
|
|
90
|
+
*/
|
|
91
|
+
export const VENDOR_MEMBER_PIN_PATTERN = new RegExp(EDITION_CONTRACT.provenancePins.customerForms.vendorMemberPinPattern);
|
|
92
|
+
|
|
93
|
+
/** Whether the contract admits customer pin form `form` (CustomerPinForm) on an edition of `kind` and `channel`. */
|
|
94
|
+
export function customerPinFormAdmitted(form, kind, channel) {
|
|
95
|
+
return EDITION_CONTRACT.provenancePins.customerForms[form].kinds[kind]?.includes(channel) === true;
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
/** The lock files an edition can carry. */
|
|
79
99
|
export const EditionLockFile = Object.freeze({
|
|
80
100
|
Distribution: 'distribution.lock.json',
|
|
@@ -91,6 +91,47 @@ function assertDescriptorVocabulary(contract) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** The customer pin forms (provenancePins.customerForms), one key per form. */
|
|
95
|
+
export const CustomerPinForm = Object.freeze({ Catalogue: 'catalogue', CatalogueFree: 'catalogue-free' });
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `provenancePins`: exactly the catalogue and definition pin statements and `customerForms`, the closed statement of how a
|
|
99
|
+
* customer edition records its vendor side. The catalogue form is admitted on every kind and channel a kind has (a customer
|
|
100
|
+
* edition that is not catalogue-free still requires the catalogue pin), the catalogue-free form on a subset of them; the
|
|
101
|
+
* vendor member pin pattern is an anchored regular expression.
|
|
102
|
+
*/
|
|
103
|
+
function assertProvenancePins(contract) {
|
|
104
|
+
const pins = contract.provenancePins;
|
|
105
|
+
const texts = ['catalogue', 'definition'];
|
|
106
|
+
if (!isObject(pins) || !sameSet(Object.keys(pins), [...texts, 'customerForms']) || !texts.every((key) => typeof pins[key] === 'string' && pins[key] !== '')) {
|
|
107
|
+
throw new Error('edition-contract.json: "provenancePins" must be exactly {catalogue, definition, customerForms}');
|
|
108
|
+
}
|
|
109
|
+
const forms = pins.customerForms;
|
|
110
|
+
const formNames = Object.values(CustomerPinForm);
|
|
111
|
+
if (!isObject(forms) || !sameSet(Object.keys(forms), ['names', 'vendorMemberPinPattern', ...formNames]) || typeof forms.names !== 'string' || forms.names === '') {
|
|
112
|
+
throw new Error(`edition-contract.json: provenancePins.customerForms must be exactly {names, vendorMemberPinPattern, ${formNames.join(', ')}}`);
|
|
113
|
+
}
|
|
114
|
+
assertAnchoredPattern(forms.vendorMemberPinPattern, 'provenancePins.customerForms.vendorMemberPinPattern');
|
|
115
|
+
const channelsOf = { [EditionKind.Resolved]: Object.values(EditionChannel), [EditionKind.Sealed]: [contract.sealOf.channel] };
|
|
116
|
+
for (const name of formNames) {
|
|
117
|
+
const form = forms[name];
|
|
118
|
+
if (!isObject(form) || !sameSet(Object.keys(form), ['pins', 'kinds']) || typeof form.pins !== 'string' || form.pins === '' || !isObject(form.kinds)) {
|
|
119
|
+
throw new Error(`edition-contract.json: provenancePins.customerForms.${name} must be exactly {pins, kinds}`);
|
|
120
|
+
}
|
|
121
|
+
for (const [kind, channels] of Object.entries(form.kinds)) {
|
|
122
|
+
const own = channelsOf[kind];
|
|
123
|
+
if (own === undefined || !Array.isArray(channels) || channels.length === 0 || new Set(channels).size !== channels.length || !channels.every((channel) => own.includes(channel))) {
|
|
124
|
+
throw new Error(`edition-contract.json: provenancePins.customerForms.${name}.kinds.${kind} must be a non-empty set of the channels of a ${kind} edition`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const [kind, own] of Object.entries(channelsOf)) {
|
|
129
|
+
if (!sameSet(forms[CustomerPinForm.Catalogue].kinds[kind], own)) {
|
|
130
|
+
throw new Error(`edition-contract.json: provenancePins.customerForms.catalogue.kinds.${kind} must name every channel of a ${kind} edition (${own.join(' | ')}): the catalogue form is the default form`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
94
135
|
const SEGMENT = /^[a-z0-9][a-z0-9.-]*$/;
|
|
95
136
|
|
|
96
137
|
/** The strict parse of the contract; returns it unchanged. */
|
|
@@ -133,5 +174,6 @@ export function parseEditionContract(contract) {
|
|
|
133
174
|
throw new Error('edition-contract.json: the channels\' namespace segments must differ');
|
|
134
175
|
}
|
|
135
176
|
assertDescriptorVocabulary(contract);
|
|
177
|
+
assertProvenancePins(contract);
|
|
136
178
|
return contract;
|
|
137
179
|
}
|
|
@@ -35,7 +35,24 @@
|
|
|
35
35
|
},
|
|
36
36
|
"provenancePins": {
|
|
37
37
|
"catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against",
|
|
38
|
-
"definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit"
|
|
38
|
+
"definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit",
|
|
39
|
+
"customerForms": {
|
|
40
|
+
"names": "a customer edition is one whose pins carry the definition pin. It records its vendor side in exactly one form, closed per kind and channel: the catalogue form, or the catalogue-free form. Every other edition carries neither form's marker: no catalogue pin. The kinds and channels each form admits are enforced by the writers and this CLI's readers (the descriptor parse); the catalogue-free form being develop only also rests on only the develop resolve identity being able to sign one. The deploy root's admission does not enforce the forms: it re-vendors this contract and reads neither provenancePins nor the forms",
|
|
41
|
+
"catalogue": {
|
|
42
|
+
"pins": "the catalogue pin, the definition pin, and a sources/<name> commit per source the definition declares; nothing else. The distribution lock names the catalogue artifact by catalogueDigest, never by catalogueContentDigest",
|
|
43
|
+
"kinds": {
|
|
44
|
+
"resolved": ["develop", "release"],
|
|
45
|
+
"sealed": ["release"]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"catalogue-free": {
|
|
49
|
+
"pins": "no catalogue pin: the vendor side was resolved from the vendor members in the same call, so the edition records the vendor member pins (keys matching vendorMemberPinPattern, at least one) as 40-hex commits, beside the definition pin, the \"definition-repository\" commit (the commit of the customer definition repository the definition was read at), the sources/<name> commits and the other 40-hex pins of the resolve. The catalogue the vendor step wrote is named in the distribution lock only, by catalogueContentDigest (sha256 of its exact catalogue.json bytes, kernel edition protocol v37), never by catalogueDigest: no catalogue artifact exists",
|
|
50
|
+
"kinds": {
|
|
51
|
+
"resolved": ["develop"]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"vendorMemberPinPattern": "^(?:repos|submodules)/[a-z0-9][a-z0-9-]*$"
|
|
55
|
+
}
|
|
39
56
|
},
|
|
40
57
|
"imageNamespaceSegment": "dev",
|
|
41
58
|
"evidenceTrust": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { X509Certificate, createPublicKey } from 'node:crypto';
|
|
2
3
|
import {
|
|
3
4
|
mkdtempSync,
|
|
4
5
|
readFileSync,
|
|
@@ -126,6 +127,76 @@ export function validatePinnedTrustedRoot(policyRoot, policy) {
|
|
|
126
127
|
return { path: trustedRoot, bytes: rootBytes };
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
/** The one transparency log a CLASSIC (`.sig` / `.att`) cosign signature is entered in: Rekor v1 of the public good instance. */
|
|
131
|
+
const CLASSIC_REKOR_BASE_URL = 'https://rekor.sigstore.dev';
|
|
132
|
+
const STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
|
|
133
|
+
|
|
134
|
+
const pemOf = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').match(/.{1,64}/gu).join('\n')}\n-----END ${label}-----\n`;
|
|
135
|
+
const isOpen = (entry) => entry?.validFor?.end === undefined;
|
|
136
|
+
|
|
137
|
+
function derOf(rawBytes, what) {
|
|
138
|
+
if (typeof rawBytes !== 'string' || rawBytes.length === 0 || !STRICT_BASE64.test(rawBytes)) {
|
|
139
|
+
throw new Error(`Sigstore trusted root: ${what} rawBytes is not base64 DER`);
|
|
140
|
+
}
|
|
141
|
+
return Buffer.from(rawBytes, 'base64');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function publicKeyPem(entry, what) {
|
|
145
|
+
const der = derOf(entry?.publicKey?.rawBytes, what);
|
|
146
|
+
try {
|
|
147
|
+
createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error(`Sigstore trusted root: ${what} is not a DER SubjectPublicKeyInfo`);
|
|
150
|
+
}
|
|
151
|
+
return pemOf('PUBLIC KEY', der);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The classic cosign trust material of the pinned Sigstore trusted root, derived deterministically from its bytes (the
|
|
156
|
+
* output of `validatePinnedTrustedRoot`): what cosign's classic verification reads from `SIGSTORE_ROOT_FILE`,
|
|
157
|
+
* `SIGSTORE_REKOR_PUBLIC_KEY` and `SIGSTORE_CT_LOG_PUBLIC_KEY_FILE` when it cannot take `--trusted-root` (cosign 2.6
|
|
158
|
+
* `verify-attestation` accepts `--trusted-root` only with `--new-bundle-format`, which classic `.att` attestations are
|
|
159
|
+
* not). Only material still open is kept (no `validFor.end`): the Fulcio certificate chains of every open certificate
|
|
160
|
+
* authority (at least one), exactly one open Rekor v1 log (`https://rekor.sigstore.dev`; later logs carry new-format
|
|
161
|
+
* entries only) and exactly one open CT log. Any other count is refused.
|
|
162
|
+
*
|
|
163
|
+
* @returns {{fulcioPem: string, rekorPem: string, ctLogPem: string}}
|
|
164
|
+
*/
|
|
165
|
+
export function classicCosignTrustMaterial(rootBytes) {
|
|
166
|
+
const root = JSON.parse(decodeExactUtf8(rootBytes, 'Sigstore trusted root'));
|
|
167
|
+
const listOf = (key) => (Array.isArray(root?.[key]) ? root[key] : []);
|
|
168
|
+
const authorities = listOf('certificateAuthorities').filter(isOpen);
|
|
169
|
+
if (authorities.length === 0) {
|
|
170
|
+
throw new Error('Sigstore trusted root: no open Fulcio certificate authority (certificateAuthorities without validFor.end)');
|
|
171
|
+
}
|
|
172
|
+
const fulcioPem = authorities
|
|
173
|
+
.flatMap((authority, index) => {
|
|
174
|
+
const certificates = authority?.certChain?.certificates;
|
|
175
|
+
if (!Array.isArray(certificates) || certificates.length === 0) {
|
|
176
|
+
throw new Error(`Sigstore trusted root: open certificate authority ${index} has no certificate chain`);
|
|
177
|
+
}
|
|
178
|
+
return certificates.map((certificate) => {
|
|
179
|
+
const der = derOf(certificate?.rawBytes, `certificate authority ${index} certificate`);
|
|
180
|
+
try {
|
|
181
|
+
new X509Certificate(der);
|
|
182
|
+
} catch {
|
|
183
|
+
throw new Error(`Sigstore trusted root: certificate authority ${index} holds a certificate that is not DER X.509`);
|
|
184
|
+
}
|
|
185
|
+
return pemOf('CERTIFICATE', der);
|
|
186
|
+
});
|
|
187
|
+
})
|
|
188
|
+
.join('');
|
|
189
|
+
const rekor = listOf('tlogs').filter((log) => log?.baseUrl === CLASSIC_REKOR_BASE_URL && isOpen(log?.publicKey));
|
|
190
|
+
if (rekor.length !== 1) {
|
|
191
|
+
throw new Error(`Sigstore trusted root: ${rekor.length} open Rekor v1 logs (${CLASSIC_REKOR_BASE_URL}); exactly one is required`);
|
|
192
|
+
}
|
|
193
|
+
const ctLogs = listOf('ctlogs').filter((log) => isOpen(log?.publicKey));
|
|
194
|
+
if (ctLogs.length !== 1) {
|
|
195
|
+
throw new Error(`Sigstore trusted root: ${ctLogs.length} open CT logs (ctlogs without validFor.end); exactly one is required`);
|
|
196
|
+
}
|
|
197
|
+
return { fulcioPem, rekorPem: publicKeyPem(rekor[0], 'the Rekor v1 log key'), ctLogPem: publicKeyPem(ctLogs[0], 'the CT log key') };
|
|
198
|
+
}
|
|
199
|
+
|
|
129
200
|
function assertTimeWindow({ issuedAt, expiresAt, now, maxTtlSeconds, label }) {
|
|
130
201
|
const issued = Date.parse(issuedAt);
|
|
131
202
|
const expires = Date.parse(expiresAt);
|
package/edition/pin-source.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { developGraphMembers } from './develop-graph.mjs';
|
|
25
25
|
import { workspaceSection } from './edition-config.mjs';
|
|
26
|
+
import { VENDOR_MEMBER_PIN_PATTERN } from './edition-constants.mjs';
|
|
26
27
|
import { compareStrings } from './lib.mjs';
|
|
27
28
|
import { PinRole } from './workspace-sections.mjs';
|
|
28
29
|
|
|
@@ -55,7 +56,8 @@ export const dataPinKey = () => pinKeyOf(PinRole.Data);
|
|
|
55
56
|
export const kernelPinKey = () => pinKeyOf(PinRole.Kernel);
|
|
56
57
|
export const deployPinKey = () => pinKeyOf(PinRole.Deploy);
|
|
57
58
|
const SOURCE_PIN_PREFIX = 'sources/';
|
|
58
|
-
|
|
59
|
+
// A graph pin key is a vendor member pin key: the pattern is the edition contract's (customerForms.vendorMemberPinPattern).
|
|
60
|
+
const GRAPH_PIN_PATTERN = VENDOR_MEMBER_PIN_PATTERN;
|
|
59
61
|
|
|
60
62
|
/** The snapshot key of one declared `sources[]` entry. */
|
|
61
63
|
export function sourcePinKey(name) {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// selection can both subclass it. A registry, cosign or git that could not answer is a plain Error instead.
|
|
4
4
|
|
|
5
5
|
export class RecordRefused extends Error {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/** `context` labels the verb the refusal surfaces in (select-records by default, `follow` for its sources). */
|
|
7
|
+
constructor(repo, message, context = 'select-records') {
|
|
8
|
+
super(`${context}: ${repo}: ${message}`);
|
|
8
9
|
this.name = 'RecordRefused';
|
|
9
10
|
this.repo = repo;
|
|
10
11
|
}
|
|
@@ -98,7 +98,8 @@ if (isEntryModule(import.meta.url)) {
|
|
|
98
98
|
const pin = await repinEvidenceTrust(parseRepinArguments(process.argv.slice(2)));
|
|
99
99
|
console.log(
|
|
100
100
|
`[repin-evidence-trust] pinned policy ${pin.policyDigest}, verifier ${pin.verifier.bundleDigest}, ` +
|
|
101
|
-
|
|
101
|
+
// A customer data root states no kernel contract authority: its pin records none.
|
|
102
|
+
`kernel contract authority ${pin.kernelContractAuthority === null ? 'none' : pin.kernelContractAuthority.sourceCommit} and ` +
|
|
102
103
|
`${pin.trustedOrasBinaryDigests.length} oras binary digest(s)`,
|
|
103
104
|
);
|
|
104
105
|
} catch (error) {
|
|
@@ -20,6 +20,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
20
20
|
|
|
21
21
|
import { adapterHostRow, assertAdapterBundlesMatchHost, fetchRegistryManifest } from './adapter-bundled-versions.mjs';
|
|
22
22
|
import { AUTHORIZATION_LOCK_FILENAME, writeAuthorizationLock } from './authorization-base.mjs';
|
|
23
|
+
import { catalogueLockFieldOf } from './catalogue-lock-field.mjs';
|
|
23
24
|
import {
|
|
24
25
|
assertCustomerLockWithinCatalogueCeiling,
|
|
25
26
|
CatalogueCeilingError,
|
|
@@ -33,8 +34,9 @@ import {
|
|
|
33
34
|
parseCustomerDefinition,
|
|
34
35
|
parseImageRegistryMirror,
|
|
35
36
|
} from './customer-definition.mjs';
|
|
37
|
+
import { assertNoUnverifiedRequiredGitBundles } from './develop-git-bundles.mjs';
|
|
36
38
|
import { assertRosteredChartSetLock } from './edition-chart-set.mjs';
|
|
37
|
-
import { EditionLockFile } from './edition-constants.mjs';
|
|
39
|
+
import { EditionChannel, EditionLockFile } from './edition-constants.mjs';
|
|
38
40
|
import { editionsRoot } from './edition-workspace.mjs';
|
|
39
41
|
import { subjectServingBiome } from './image-lock-service-rows.mjs';
|
|
40
42
|
import { adoptCatalogueKernel } from './kernel-package.mjs';
|
|
@@ -212,8 +214,29 @@ export function assertVendorRowsAreCatalogueRows({ catalogue, imageLock, lock, m
|
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The customer's OWN lineage of a previous images lock: `previousLock` (the customer edition's own last images lock, its
|
|
219
|
+
* id already held to this edition's) narrowed to the composed-process rows every owner of which is one of this lock's
|
|
220
|
+
* own (customer-sourced) biomes. The vendor rows of a customer lock are the catalogue's (assertVendorRowsAreCatalogueRows)
|
|
221
|
+
* and carry the vendor's generations, never the customer's, so they never enter the customer's own carry-forward.
|
|
222
|
+
* Null (a first seal) stays null.
|
|
223
|
+
*/
|
|
224
|
+
export function ownComposedPreviousLock(previousLock, lock, enums) {
|
|
225
|
+
if (previousLock === null) {return null;}
|
|
226
|
+
const ownBiomes = new Set(lock.biomes.filter((biome) => biome.source !== undefined).map((biome) => biome.id));
|
|
227
|
+
const own = (subject) =>
|
|
228
|
+
subject.kind === enums.RuntimeImageSubjectKind.ComposedProcess &&
|
|
229
|
+
subject.owners.length > 0 &&
|
|
230
|
+
subject.owners.every((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && ownBiomes.has(owner.biomeId));
|
|
231
|
+
return { ...previousLock, subjects: previousLock.subjects.filter(own) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes
|
|
236
|
+
* and returns subjects), the merged lock parsed once. `previousLock` (the customer's own previous seal, null for a first
|
|
237
|
+
* seal) carries the own composed artifacts' generations forward (ownComposedPreviousLock).
|
|
238
|
+
*/
|
|
239
|
+
export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn, previousLock = null }) {
|
|
217
240
|
const vendor = projectVendorImages(catalogue, lock, schemas);
|
|
218
241
|
const vendorSubjects = mirrorVendorSubjects(vendor.subjects, composed.mirror);
|
|
219
242
|
const ownBiomes = lock.biomes.filter((biome) => biome.source !== undefined);
|
|
@@ -231,7 +254,7 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
|
|
|
231
254
|
buildStepContextDirectories: [],
|
|
232
255
|
};
|
|
233
256
|
// The own rows are ROWS, not a lock: the one parse is of the merged lock below.
|
|
234
|
-
own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock });
|
|
257
|
+
own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock, previousLock: ownComposedPreviousLock(previousLock, lock, schemas) });
|
|
235
258
|
// A source that builds no image (a web biome) has no row to seal.
|
|
236
259
|
if (sealOwn !== undefined && own.subjects.length > 0) {own = { ...own, subjects: await sealOwn({ distributionId: id, subjects: own.subjects }) };}
|
|
237
260
|
}
|
|
@@ -257,19 +280,26 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
|
|
|
257
280
|
|
|
258
281
|
/**
|
|
259
282
|
* The distribution and authorization locks of the customer edition `rawDefinition`
|
|
260
|
-
* against `catalogue`.
|
|
283
|
+
* against `catalogue`. Exactly one of `catalogueDigest` (a published catalogue artifact) and `catalogueContentDigest` (the
|
|
284
|
+
* catalogue bytes of a catalogue-free resolve) names `catalogue`; it is written into the lock under its own key. `adoptKernel` (the catalogue's kernel from npm, proved its contract authority; returns the kernel schemas) and `materialize` are injectable for the test suites.
|
|
261
285
|
*/
|
|
262
286
|
export async function resolveCustomerDistribution({
|
|
263
287
|
rawDefinition,
|
|
264
288
|
catalogue,
|
|
265
289
|
catalogueDigest,
|
|
290
|
+
catalogueContentDigest,
|
|
266
291
|
sourceImages,
|
|
267
292
|
pins,
|
|
268
293
|
materialize,
|
|
269
294
|
adoptKernel = adoptCatalogueKernel,
|
|
270
295
|
fetchManifest = fetchRegistryManifest,
|
|
296
|
+
developGitBundles,
|
|
271
297
|
}) {
|
|
272
|
-
|
|
298
|
+
const catalogueField = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what: 'a customer resolve', ErrorType: CatalogueError });
|
|
299
|
+
// Only a develop edition carries a git-sourced OCI bundle (develop-git-bundles.mjs); a release never verifies one.
|
|
300
|
+
if (developGitBundles !== undefined && catalogue.channel !== EditionChannel.Develop) {
|
|
301
|
+
throw new Error(`a git-sourced bundle verifier is a develop resolve's; the catalogue "${catalogue.offeringId}" is ${catalogue.channel}`);
|
|
302
|
+
}
|
|
273
303
|
const schemas = await adoptKernel(catalogue);
|
|
274
304
|
const { definition, mirror } = parseCustomerDefinition(schemas, rawDefinition);
|
|
275
305
|
const composed = composeCustomerEdition({ schemas, definition, mirror, catalogue, sourceImages, pins });
|
|
@@ -284,6 +314,8 @@ export async function resolveCustomerDistribution({
|
|
|
284
314
|
platformServiceIndex: { schemaVersion: 1, services: catalogue.platformServices },
|
|
285
315
|
sourcesAvailable: false,
|
|
286
316
|
storePublisherTrust: selectedStorePublisherTrust(catalogueCeiling(catalogue, schemas).storePublisherTrust, definition.storePublisherAnchorKeys),
|
|
317
|
+
// A develop resolve under a declared customer policy verifies its git-sourced OCI bundles and keeps them (by digest, with the signer).
|
|
318
|
+
...(developGitBundles === undefined ? {} : { developGitBundles }),
|
|
287
319
|
};
|
|
288
320
|
let resolved;
|
|
289
321
|
try {
|
|
@@ -295,6 +327,9 @@ export async function resolveCustomerDistribution({
|
|
|
295
327
|
throw error;
|
|
296
328
|
}
|
|
297
329
|
const { lock, index } = resolved;
|
|
330
|
+
// Without a verifier no git-sourced OCI bundle is verified, so a required row that declares one is refused, never stripped.
|
|
331
|
+
// With one, resolve-distribution already refuses every such row it did not verify.
|
|
332
|
+
if (developGitBundles === undefined) {assertNoUnverifiedRequiredGitBundles({ lock, index, required: schemas.BiomeInstallPolicy.Required });}
|
|
298
333
|
assertTrustCeilingAssignsCustomerBiomes(
|
|
299
334
|
schemas,
|
|
300
335
|
definition,
|
|
@@ -306,14 +341,19 @@ export async function resolveCustomerDistribution({
|
|
|
306
341
|
await assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(catalogue.biomes), fetchManifest });
|
|
307
342
|
const authorizationLock = await composeAuthorizationBase(id, lock, index);
|
|
308
343
|
// A customer lock names the catalogue it was resolved against; seal and verify refuse any other.
|
|
309
|
-
return { id, schemas, composed, inputs, lock: { ...lock,
|
|
344
|
+
return { id, schemas, composed, inputs, lock: { ...lock, [catalogueField.key]: catalogueField.digest }, authorizationLock, storePublisherAnchorKeys };
|
|
310
345
|
}
|
|
311
346
|
|
|
312
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* Resolve the customer edition of `channel` against `catalogue`; returns the four locks. `sealOwn` seals the customer's OWN
|
|
349
|
+
* image rows only (a develop edition); vendor rows arrive sealed. `previousSeal` answers the customer's own previous seal
|
|
350
|
+
* (resolvePreviousSeal under this edition's id: null for a first seal), read only after the catalogue's kernel is adopted
|
|
351
|
+
* (it parses with that kernel); its own composed artifacts carry their generation forward. Absent: a first seal.
|
|
352
|
+
*/
|
|
313
353
|
export async function resolveCustomerEdition(request) {
|
|
314
354
|
const { id, schemas, composed, inputs, lock, authorizationLock, storePublisherAnchorKeys } = await resolveCustomerDistribution(request);
|
|
315
355
|
const { catalogue } = request;
|
|
316
|
-
const imageLock = await resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn: request.sealOwn });
|
|
356
|
+
const imageLock = await resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn: request.sealOwn, previousLock: request.previousSeal === undefined ? null : await request.previousSeal(id) });
|
|
317
357
|
// The chart-set is copied whole, so it carries the coordinates of the channel it was published on:
|
|
318
358
|
// the edition being written states its channel, and a chart-set of the other one is refused here.
|
|
319
359
|
const chartSetLock = assertRosteredChartSetLock(catalogue.chartSet, `the catalogue's chart-set of "${catalogue.offeringId}"`);
|
|
@@ -1823,7 +1823,8 @@ export function resolveUnadmittedDistributionFromInputs(
|
|
|
1823
1823
|
// resolve verified for it (`develop-git-bundles.mjs`: the develop signer at the attested commit, content-bound to
|
|
1824
1824
|
// the frozen one), by digest, and its develop bundle signer. A git-sourced OCI row the resolve did not verify is
|
|
1825
1825
|
// refused, never stripped;
|
|
1826
|
-
// - otherwise (release, customer, dist-check) it carries neither, as before; the release refuses the declaration
|
|
1826
|
+
// - otherwise (release, customer, dist-check) it carries neither, as before; the release refuses the declaration,
|
|
1827
|
+
// and a customer resolve refuses it on a required row (develop-git-bundles.mjs `assertNoUnverifiedRequiredGitBundles`).
|
|
1827
1828
|
// An OCI host-module bundle on a git-sourced biome is refused below in every channel. (An entry of a declared `oci`
|
|
1828
1829
|
// source has a stamp but no record: not git.)
|
|
1829
1830
|
const isGitSourced = (e) => e.source !== undefined && sourceRecords.has(e.source);
|
|
@@ -158,6 +158,9 @@ export function parseCustomerResolveArgs(args) {
|
|
|
158
158
|
if (!Object.values(EditionChannel).includes(values.get(CHANNEL_FLAG))) {
|
|
159
159
|
throw new Error(`${CHANNEL_FLAG} must be one of ${Object.values(EditionChannel).join(' | ')}`);
|
|
160
160
|
}
|
|
161
|
+
if (values.get(CHANNEL_FLAG) === EditionChannel.Develop) {
|
|
162
|
+
throw new Error(`${DEFINITION_FLAG} with ${CHANNEL_FLAG} develop is refused here: this verb takes no previous seal, so the customer's own composed rows would restart at generation 1; use xema edition develop resolve ${DEFINITION_FLAG} <file> --previous-lock <file> --previous-lock-sha256 <hex> | --first-seal`);
|
|
163
|
+
}
|
|
161
164
|
return { values, sourceImages: parseSourceImages(sourceImages) };
|
|
162
165
|
}
|
|
163
166
|
|