@xemahq/xema 0.58.0 → 0.59.1
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/assemble-producer-input.mjs +15 -6
- 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 +86 -20
- package/edition/third-party-bundle-registry.mjs +54 -17
- package/package.json +3 -3
|
@@ -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
|
}
|
|
@@ -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,
|
|
@@ -213,8 +214,29 @@ export function assertVendorRowsAreCatalogueRows({ catalogue, imageLock, lock, m
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
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 }) {
|
|
218
240
|
const vendor = projectVendorImages(catalogue, lock, schemas);
|
|
219
241
|
const vendorSubjects = mirrorVendorSubjects(vendor.subjects, composed.mirror);
|
|
220
242
|
const ownBiomes = lock.biomes.filter((biome) => biome.source !== undefined);
|
|
@@ -232,7 +254,7 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
|
|
|
232
254
|
buildStepContextDirectories: [],
|
|
233
255
|
};
|
|
234
256
|
// The own rows are ROWS, not a lock: the one parse is of the merged lock below.
|
|
235
|
-
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) });
|
|
236
258
|
// A source that builds no image (a web biome) has no row to seal.
|
|
237
259
|
if (sealOwn !== undefined && own.subjects.length > 0) {own = { ...own, subjects: await sealOwn({ distributionId: id, subjects: own.subjects }) };}
|
|
238
260
|
}
|
|
@@ -258,12 +280,14 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
|
|
|
258
280
|
|
|
259
281
|
/**
|
|
260
282
|
* The distribution and authorization locks of the customer edition `rawDefinition`
|
|
261
|
-
* 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.
|
|
262
285
|
*/
|
|
263
286
|
export async function resolveCustomerDistribution({
|
|
264
287
|
rawDefinition,
|
|
265
288
|
catalogue,
|
|
266
289
|
catalogueDigest,
|
|
290
|
+
catalogueContentDigest,
|
|
267
291
|
sourceImages,
|
|
268
292
|
pins,
|
|
269
293
|
materialize,
|
|
@@ -271,7 +295,7 @@ export async function resolveCustomerDistribution({
|
|
|
271
295
|
fetchManifest = fetchRegistryManifest,
|
|
272
296
|
developGitBundles,
|
|
273
297
|
}) {
|
|
274
|
-
|
|
298
|
+
const catalogueField = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what: 'a customer resolve', ErrorType: CatalogueError });
|
|
275
299
|
// Only a develop edition carries a git-sourced OCI bundle (develop-git-bundles.mjs); a release never verifies one.
|
|
276
300
|
if (developGitBundles !== undefined && catalogue.channel !== EditionChannel.Develop) {
|
|
277
301
|
throw new Error(`a git-sourced bundle verifier is a develop resolve's; the catalogue "${catalogue.offeringId}" is ${catalogue.channel}`);
|
|
@@ -317,14 +341,19 @@ export async function resolveCustomerDistribution({
|
|
|
317
341
|
await assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(catalogue.biomes), fetchManifest });
|
|
318
342
|
const authorizationLock = await composeAuthorizationBase(id, lock, index);
|
|
319
343
|
// A customer lock names the catalogue it was resolved against; seal and verify refuse any other.
|
|
320
|
-
return { id, schemas, composed, inputs, lock: { ...lock,
|
|
344
|
+
return { id, schemas, composed, inputs, lock: { ...lock, [catalogueField.key]: catalogueField.digest }, authorizationLock, storePublisherAnchorKeys };
|
|
321
345
|
}
|
|
322
346
|
|
|
323
|
-
/**
|
|
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
|
+
*/
|
|
324
353
|
export async function resolveCustomerEdition(request) {
|
|
325
354
|
const { id, schemas, composed, inputs, lock, authorizationLock, storePublisherAnchorKeys } = await resolveCustomerDistribution(request);
|
|
326
355
|
const { catalogue } = request;
|
|
327
|
-
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) });
|
|
328
357
|
// The chart-set is copied whole, so it carries the coordinates of the channel it was published on:
|
|
329
358
|
// the edition being written states its channel, and a chart-set of the other one is refused here.
|
|
330
359
|
const chartSetLock = assertRosteredChartSetLock(catalogue.chartSet, `the catalogue's chart-set of "${catalogue.offeringId}"`);
|
|
@@ -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
|
|
|
@@ -947,16 +947,24 @@ export function structureSubjects(structure) {
|
|
|
947
947
|
* entrypoint, owned by the biomes whose services it carries. Neither the kernel
|
|
948
948
|
* nor the SDK fixes WHERE a repository builds it, so this plane does, once:
|
|
949
949
|
*
|
|
950
|
-
* - the image directory is
|
|
951
|
-
*
|
|
950
|
+
* - the image directory is the per-class directory
|
|
951
|
+
* `infra/composed-process/<class>/` at the repository root (`<class>` a
|
|
952
|
+
* kernel `ComposedImageClass`; an edition addresses one composed subject
|
|
953
|
+
* per repository, of the kernel's `DEFAULT_COMPOSED_IMAGE_CLASS`). A commit
|
|
954
|
+
* that predates the per-class layout declares it at the legacy root
|
|
955
|
+
* `infra/composed-process/`, which stays readable because committed release
|
|
956
|
+
* locks still pin such commits. Both at once, or a declaration in another
|
|
957
|
+
* class directory, is refused (`composedProcessDeclarationDir`). The
|
|
958
|
+
* directory holds its own `Dockerfile` and an `image-build-inputs.json` whose
|
|
952
959
|
* `workspacePackages` lists every member service's workspace package
|
|
953
960
|
* directory — the Dockerfile-only image shape `hashImageBuildInputs`
|
|
954
961
|
* already reads (`opencode-worker` is the other one). Its tag is therefore
|
|
955
962
|
* an ordinary `xema-image-input-sha256-v2` key, computed by the one
|
|
956
963
|
* implementation every producer uses, with `SERVICE` = the subject id and
|
|
957
964
|
* `SERVICE_PATH` = this directory;
|
|
958
|
-
* - the DECLARATION is that `image-build-inputs.json` existing
|
|
959
|
-
* commit. A repository that declares none
|
|
965
|
+
* - the DECLARATION is that `image-build-inputs.json` existing in that
|
|
966
|
+
* directory at the pinned commit. A repository that declares none (and has
|
|
967
|
+
* no `infra/composed-process/` tree at all) has no composed image, and an
|
|
960
968
|
* edition carries no composed subject for it — which the deploy plane
|
|
961
969
|
* reports as "the producer builds no composed-process image at that
|
|
962
970
|
* commit". That is an honest absence, not a skipped subject: a DECLARED
|
|
@@ -1045,8 +1053,8 @@ function declaredImageClass(declared, subject, enums) {
|
|
|
1045
1053
|
return parsed.data;
|
|
1046
1054
|
}
|
|
1047
1055
|
|
|
1048
|
-
function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
|
|
1049
|
-
const inputsPath = `${
|
|
1056
|
+
function composedProcessCarriage(structure, enums, repo, sourceCommit, locate, declarationDir) {
|
|
1057
|
+
const inputsPath = `${declarationDir}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
|
|
1050
1058
|
const { dir } = locate(repo);
|
|
1051
1059
|
let members;
|
|
1052
1060
|
try {
|
|
@@ -1098,6 +1106,60 @@ function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
|
|
|
1098
1106
|
.sort((a, b) => compareStrings(a.biomeId, b.biomeId));
|
|
1099
1107
|
}
|
|
1100
1108
|
|
|
1109
|
+
function gitPathExistsAtCommit(dir, sourceCommit, repoPath) {
|
|
1110
|
+
try {
|
|
1111
|
+
execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}:${repoPath}`], {
|
|
1112
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1113
|
+
});
|
|
1114
|
+
return true;
|
|
1115
|
+
} catch {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The repo-relative directory holding a repository's composed-process
|
|
1122
|
+
* DECLARATION at one commit, or null when it declares none. The default class's
|
|
1123
|
+
* directory and the legacy root are the two places an edition reads; a
|
|
1124
|
+
* declaration it cannot address (both present, another class, an
|
|
1125
|
+
* `infra/composed-process/` tree without a recognised one) is refused, because
|
|
1126
|
+
* a declaration the resolver cannot find must never read as "declares nothing".
|
|
1127
|
+
*/
|
|
1128
|
+
function composedProcessDeclarationDir(dir, sourceCommit, enums) {
|
|
1129
|
+
const defaultClass = enums.DEFAULT_COMPOSED_IMAGE_CLASS;
|
|
1130
|
+
const classDir = `${COMPOSED_PROCESS_IMAGE_PATH}/${defaultClass}`;
|
|
1131
|
+
const declares = (candidate) =>
|
|
1132
|
+
gitPathExistsAtCommit(dir, sourceCommit, `${candidate}/${COMPOSED_PROCESS_DECLARATION_FILE}`);
|
|
1133
|
+
const inClassDir = declares(classDir);
|
|
1134
|
+
const atLegacyRoot = declares(COMPOSED_PROCESS_IMAGE_PATH);
|
|
1135
|
+
if (inClassDir && atLegacyRoot) {
|
|
1136
|
+
throw new ComposedProcessInputsError(
|
|
1137
|
+
`ambiguous composed-process declaration at ${sourceCommit}: both "${classDir}/${COMPOSED_PROCESS_DECLARATION_FILE}" ` +
|
|
1138
|
+
`and the legacy "${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}" exist`,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
const otherClasses = enums.ComposedImageClassSchema.options
|
|
1142
|
+
.filter((candidate) => candidate !== defaultClass)
|
|
1143
|
+
.filter((candidate) => declares(`${COMPOSED_PROCESS_IMAGE_PATH}/${candidate}`));
|
|
1144
|
+
if (otherClasses.length > 0) {
|
|
1145
|
+
throw new ComposedProcessInputsError(
|
|
1146
|
+
`composed-process declaration at ${sourceCommit} is in the "${otherClasses.join('", "')}" class ` +
|
|
1147
|
+
`director${otherClasses.length > 1 ? 'ies' : 'y'}, but an edition addresses one composed-process subject per ` +
|
|
1148
|
+
`repository, of the default class "${defaultClass}"; a "${otherClasses[0]}" image has no subject naming yet`,
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
if (inClassDir) {return classDir;}
|
|
1152
|
+
if (atLegacyRoot) {return COMPOSED_PROCESS_IMAGE_PATH;}
|
|
1153
|
+
if (gitPathExistsAtCommit(dir, sourceCommit, COMPOSED_PROCESS_IMAGE_PATH)) {
|
|
1154
|
+
throw new ComposedProcessInputsError(
|
|
1155
|
+
`"${COMPOSED_PROCESS_IMAGE_PATH}" exists at ${sourceCommit} but declares no composed-process image the edition can ` +
|
|
1156
|
+
`read: looked at "${classDir}/${COMPOSED_PROCESS_DECLARATION_FILE}" and ` +
|
|
1157
|
+
`"${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}"`,
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1101
1163
|
/**
|
|
1102
1164
|
* Read each repository's composed-process DECLARATION at the commit the
|
|
1103
1165
|
* edition builds its services from. Only repositories that contribute a
|
|
@@ -1119,7 +1181,6 @@ export function readComposedProcessDeclarations(
|
|
|
1119
1181
|
const declarations = [];
|
|
1120
1182
|
for (const [repo, sourceCommit] of [...commits].sort(([a], [b]) => compareStrings(a, b))) {
|
|
1121
1183
|
const { dir, base } = locate(repo);
|
|
1122
|
-
const declarationPath = `${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
|
|
1123
1184
|
// ABSENCE IS ONLY AN ANSWER ABOUT A COMMIT THAT WAS READ. A repository
|
|
1124
1185
|
// missing from this layout, or one lacking the pinned commit, would
|
|
1125
1186
|
// otherwise read as "declares nothing", and `dist-check` would grade a lock
|
|
@@ -1135,21 +1196,17 @@ export function readComposedProcessDeclarations(
|
|
|
1135
1196
|
'before resolving',
|
|
1136
1197
|
);
|
|
1137
1198
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
});
|
|
1142
|
-
} catch {
|
|
1143
|
-
continue; // declares no composed-process image at this commit
|
|
1144
|
-
}
|
|
1199
|
+
const declarationDir = composedProcessDeclarationDir(dir, sourceCommit, enums);
|
|
1200
|
+
if (declarationDir === null) {continue;} // declares no composed-process image at this commit
|
|
1201
|
+
const declarationPath = `${declarationDir}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
|
|
1145
1202
|
const sourceId = sourceIds.get(repo);
|
|
1146
1203
|
const repoRelative = relative(base, dir).split(sep).join('/');
|
|
1147
1204
|
declarations.push({
|
|
1148
1205
|
repo,
|
|
1149
1206
|
sourceId,
|
|
1150
1207
|
sourceCommit,
|
|
1151
|
-
carriage: composedProcessCarriage(structure, enums, repo, sourceCommit, locate),
|
|
1152
|
-
path: `${repoRelative}/${
|
|
1208
|
+
carriage: composedProcessCarriage(structure, enums, repo, sourceCommit, locate, declarationDir),
|
|
1209
|
+
path: `${repoRelative}/${declarationDir}`,
|
|
1153
1210
|
sourceManifestPath: `${repoRelative}/${declarationPath}`,
|
|
1154
1211
|
sourceManifestHash: exactSourceManifestHash(dir, declarationPath, sourceCommit),
|
|
1155
1212
|
});
|
|
@@ -2126,12 +2183,21 @@ export async function resolvePreviousSeal({ distributionId, previousLockPath, pr
|
|
|
2126
2183
|
: `--previous-lock and --previous-lock-sha256 go together (one was given); give ${PREVIOUS_SEAL_USAGE}`,
|
|
2127
2184
|
);
|
|
2128
2185
|
}
|
|
2129
|
-
const bytes =
|
|
2186
|
+
const bytes = readPreviousLockBytes(previousLockPath, previousLockSha256);
|
|
2187
|
+
return parsePreviousLock(distributionId, parseStrictJson(bytes.toString('utf8'), previousLockPath), '--previous-lock');
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* The bytes of a previous lock file, held to the sha256 (hex) they were given with: the one check of the pair
|
|
2192
|
+
* (`flags` names the pair in the refusal). The parse, with the kernel and against the edition's id, is the caller's.
|
|
2193
|
+
*/
|
|
2194
|
+
export function readPreviousLockBytes(path, sha256, flags = { lock: '--previous-lock', sha256: '--previous-lock-sha256' }) {
|
|
2195
|
+
const bytes = readFileSync(path);
|
|
2130
2196
|
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
2131
|
-
if (actual !==
|
|
2132
|
-
throw new Error(
|
|
2197
|
+
if (actual !== sha256) {
|
|
2198
|
+
throw new Error(`${flags.lock} ${path} has sha256 ${actual}, not the given ${flags.sha256} ${sha256}`);
|
|
2133
2199
|
}
|
|
2134
|
-
return
|
|
2200
|
+
return bytes;
|
|
2135
2201
|
}
|
|
2136
2202
|
|
|
2137
2203
|
/** The in-repo lane's previous lock: the committed file at `outPath`, parsed before it is overwritten; null (first seal) when there is none. */
|