@xemahq/xema 0.45.1 → 0.46.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/biome.d.ts.map +1 -1
- package/dist/commands/biome.js +3 -0
- package/dist/commands/biome.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +1613 -846
- package/dist/edition/verify-image-evidence.bundle.provenance.json +516 -474
- package/dist/runtime/biome-publish-image-layers.d.ts +20 -0
- package/dist/runtime/biome-publish-image-layers.d.ts.map +1 -0
- package/dist/runtime/biome-publish-image-layers.js +64 -0
- package/dist/runtime/biome-publish-image-layers.js.map +1 -0
- package/dist/runtime/biome-publish.d.ts +4 -0
- package/dist/runtime/biome-publish.d.ts.map +1 -1
- package/dist/runtime/biome-publish.js +25 -5
- package/dist/runtime/biome-publish.js.map +1 -1
- package/dist/runtime/config/schema.d.ts +4 -3
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/config/schema.js +8 -2
- package/dist/runtime/config/schema.js.map +1 -1
- package/dist/runtime/installation/contract.d.ts +2 -2
- package/dist/runtime/installation/contract.d.ts.map +1 -1
- package/dist/runtime/installation/contract.js +1 -1
- package/dist/runtime/installation/contract.js.map +1 -1
- package/edition/README.md +2 -1
- package/edition/assemble-producer-input.mjs +2 -0
- package/edition/biome-content.mjs +6 -0
- package/edition/build-platform-service-index.mjs +2 -0
- package/edition/canonical-repository-url.mjs +12 -0
- package/edition/cosign-certificate-constraints.mjs +40 -0
- package/edition/customer-edition-verbs.mjs +6 -0
- package/edition/develop-coverage-readers.mjs +6 -4
- package/edition/develop-edition.mjs +20 -0
- package/edition/develop-record-ancestry.mjs +121 -0
- package/edition/develop-select-records.mjs +365 -0
- package/edition/distribution-evidence-set.mjs +14 -2
- package/edition/edition-config.mjs +27 -1
- package/edition/edition-protocol.mjs +8 -1
- package/edition/image-evidence.mjs +2 -12
- package/edition/kernel-signer-kind.mjs +23 -0
- package/edition/lib.mjs +104 -2
- package/edition/oci-evidence-verbs.mjs +2 -2
- package/edition/oci-image-source-evidence.mjs +84 -39
- package/edition/operation-grant-admission.mjs +98 -4
- package/edition/operation-requirements.mjs +118 -4
- package/edition/plan-image-evidence-refresh.mjs +20 -6
- package/edition/platform-service-class.mjs +23 -0
- package/edition/platform-service-ownership.mjs +23 -13
- package/edition/produce-distribution-completeness-evidence.mjs +2 -0
- package/edition/produce-image-source-evidence.mjs +9 -11
- package/edition/producer-evidence-bundle.mjs +12 -1
- package/edition/record-refused.mjs +11 -0
- package/edition/resolve-distribution.mjs +43 -1
- package/package.json +3 -3
|
@@ -33,3 +33,15 @@ export function canonicalPublicHttpsRepositoryUrl(raw, context) {
|
|
|
33
33
|
}
|
|
34
34
|
return parsed.toString();
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `owner/name` of a canonical public HTTPS repository URL (`https://host/owner/name[.git]`): exactly two
|
|
39
|
+
* path segments, the optional `.git` suffix dropped. Anything else is refused, naming `context`.
|
|
40
|
+
*/
|
|
41
|
+
export function repositoryOwnerName(canonicalUrl, context) {
|
|
42
|
+
const segments = new URL(canonicalUrl).pathname.replace(/\.git$/, '').split('/').filter((segment) => segment.length > 0);
|
|
43
|
+
if (segments.length !== 2) {
|
|
44
|
+
throw new Error(`${context} must name exactly <owner>/<name> on its host; got ${JSON.stringify(canonicalUrl)}`);
|
|
45
|
+
}
|
|
46
|
+
return segments.join('/');
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// cosign-certificate-constraints — the certificate claims every keyless verification here pins.
|
|
2
|
+
//
|
|
3
|
+
// One signer shape, `{certificateIdentity, oidcIssuer, githubWorkflow: {repository, ref}}`, plus the exact
|
|
4
|
+
// workflow commit the certificate must carry. Every `cosign verify*` of this CLI takes its constraints
|
|
5
|
+
// from here, so no call site can leave one of the five out.
|
|
6
|
+
|
|
7
|
+
const WORKFLOW_SHA = /^[0-9a-f]{40}$/;
|
|
8
|
+
|
|
9
|
+
function requireText(value, what) {
|
|
10
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
11
|
+
throw new Error(`certificate constraints: ${what} must be a non-empty string; got ${JSON.stringify(value)}`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ certificateIdentity: string, oidcIssuer: string, githubWorkflow: { repository: string, ref: string } }} signer
|
|
18
|
+
* @param {string} workflowSha the 40-hex commit the signing workflow ran at
|
|
19
|
+
* @returns {string[]} the cosign flags that pin identity, issuer, workflow repository, ref and sha
|
|
20
|
+
*/
|
|
21
|
+
export function certificateConstraintArgs(signer, workflowSha) {
|
|
22
|
+
if (signer === null || typeof signer !== 'object') {
|
|
23
|
+
throw new Error('certificate constraints: the signer must be an object');
|
|
24
|
+
}
|
|
25
|
+
if (typeof workflowSha !== 'string' || !WORKFLOW_SHA.test(workflowSha)) {
|
|
26
|
+
throw new Error(`certificate constraints: the workflow sha must be 40 lowercase hex characters; got ${JSON.stringify(workflowSha)}`);
|
|
27
|
+
}
|
|
28
|
+
return [
|
|
29
|
+
'--certificate-identity',
|
|
30
|
+
requireText(signer.certificateIdentity, 'certificateIdentity'),
|
|
31
|
+
'--certificate-oidc-issuer',
|
|
32
|
+
requireText(signer.oidcIssuer, 'oidcIssuer'),
|
|
33
|
+
'--certificate-github-workflow-repository',
|
|
34
|
+
requireText(signer.githubWorkflow?.repository, 'githubWorkflow.repository'),
|
|
35
|
+
'--certificate-github-workflow-ref',
|
|
36
|
+
requireText(signer.githubWorkflow?.ref, 'githubWorkflow.ref'),
|
|
37
|
+
'--certificate-github-workflow-sha',
|
|
38
|
+
workflowSha,
|
|
39
|
+
];
|
|
40
|
+
}
|
|
@@ -213,6 +213,12 @@ const COMMANDS = Object.freeze({
|
|
|
213
213
|
switches: ['--json'],
|
|
214
214
|
required: ['--pins', '--cosign'],
|
|
215
215
|
},
|
|
216
|
+
'select-records': {
|
|
217
|
+
positional: false,
|
|
218
|
+
values: ['--branch', '--registry', '--cosign', '--out', '--at', '--published-pins'],
|
|
219
|
+
switches: [],
|
|
220
|
+
required: ['--branch', '--registry', '--cosign', '--out'],
|
|
221
|
+
},
|
|
216
222
|
'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
|
|
217
223
|
'choose-kernel': { positional: false, values: ['--kernel-pin', '--out'], switches: [], required: ['--out'] },
|
|
218
224
|
'check-existing': {
|
|
@@ -12,7 +12,7 @@ import { promisify } from 'node:util';
|
|
|
12
12
|
|
|
13
13
|
import { AttestationStatus, DEVELOP_EVIDENCE_CHANNEL } from './develop-coverage-verdict.mjs';
|
|
14
14
|
import { COSIGN_ENV, policyCandidates, sha256Digest, validatePinnedTrustedRoot, verifySigstoreEvidenceDescriptor } from './image-evidence.mjs';
|
|
15
|
-
import { resolveImageSourceEvidenceReferrer,
|
|
15
|
+
import { resolveImageSourceEvidenceReferrer, writeInvalidReferrerReport } from './oci-image-source-evidence.mjs';
|
|
16
16
|
import { BUNDLE_MEDIA_TYPE } from './produce-image-source-evidence.mjs';
|
|
17
17
|
import { loadRegistryCredentialDeclarations, resolveRegistryCredential, splitImageRef } from './registry-credentials.mjs';
|
|
18
18
|
import { isNotPublishedYet } from './seal-image-digests.mjs';
|
|
@@ -59,7 +59,8 @@ export function parseCosignVerifyOutput(stdout, digest) {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/** Run cosign once; resolves `{code, stdout, stderr}` for a cosign exit, throws when cosign could not run at all. */
|
|
63
|
+
export function defaultRunCosign(cosignPath, args, env) {
|
|
63
64
|
return execFileAsync(cosignPath, args, { env, timeout: COSIGN_CALL_TIMEOUT_MS, maxBuffer: COSIGN_MAX_OUTPUT_BYTES }).then(
|
|
64
65
|
({ stdout }) => ({ code: 0, stdout, stderr: '' }),
|
|
65
66
|
(error) => {
|
|
@@ -69,7 +70,8 @@ function defaultRunCosign(cosignPath, args, env) {
|
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
/** The username and password of a resolved basic-auth registry credential. */
|
|
74
|
+
export function basicAuthParts(credential) {
|
|
73
75
|
const decoded = Buffer.from(credential.basicAuth, 'base64').toString('utf8');
|
|
74
76
|
const colon = decoded.indexOf(':');
|
|
75
77
|
return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
|
|
@@ -143,7 +145,7 @@ export function createCoverageReaders({ policy, policyRoot, trustedCosign, schem
|
|
|
143
145
|
subjectDigest: digest,
|
|
144
146
|
schemas,
|
|
145
147
|
expectedContractAuthority,
|
|
146
|
-
|
|
148
|
+
onInvalidReferrer: writeInvalidReferrerReport,
|
|
147
149
|
});
|
|
148
150
|
if (evidence === null) {return { status: AttestationStatus.None };}
|
|
149
151
|
if (rows.length === 0) {return { status: AttestationStatus.NoRow };}
|
|
@@ -45,6 +45,22 @@
|
|
|
45
45
|
// annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
|
|
46
46
|
// and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
|
|
47
47
|
// not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
|
|
48
|
+
// select-records --branch develop --registry <root> --cosign <absolute-path> --out <file> [--at <ISO-8601 UTC>] [--published-pins <file>]
|
|
49
|
+
// for every develop-graph member, take the newest build record the data root's record workflow signed for
|
|
50
|
+
// the branch (<root>/build-records/<name>, verified offline by digest against the pinned trusted root, as
|
|
51
|
+
// signed by <data root>/.github/workflows/record-build.yaml at refs/heads/<branch>, called by the member at the
|
|
52
|
+
// record's own commit) and write { pins, snapshot, images }, once (wx). Any missing, unsigned, mismatched or
|
|
53
|
+
// unreadable record refuses the whole run and writes nothing: a newer bad record is never skipped for an older
|
|
54
|
+
// one. --at takes the newest record completed at or before that time. Only `develop` has a declared signer; the
|
|
55
|
+
// data root must declare dataRootRepositoryUrl. Takes the roots `resolve` takes (no registry root is implied).
|
|
56
|
+
// "Newest" is the newest COMMIT on develop, not the highest run: EVERY record of the branch is read and cosign-verified,
|
|
57
|
+
// the selected commit must equal or descend from every other record's commit (git merge-base on develop fetched
|
|
58
|
+
// from the member), and among that commit's records the highest run and attempt wins. --published-pins takes a file
|
|
59
|
+
// this verb wrote (only .pins is read): a member's published commit is kept when the records are not ahead of it
|
|
60
|
+
// (its own record must exist), a diverged one is refused. Without the flag the rule applies among the records only;
|
|
61
|
+
// part 07 passes the flag.
|
|
62
|
+
// Residual: anyone who can delete registry tags can remove newer records, and this then pins the newest remaining
|
|
63
|
+
// valid one; the guard is part 07's, deploy-latest's owner refuses a snapshot that goes backwards.
|
|
48
64
|
// choose-kernel [--kernel-pin <40-hex>] --out <file>
|
|
49
65
|
// choose the develop kernel commit in the --kernel checkout (full history; its refs/remotes/origin/develop is the develop head):
|
|
50
66
|
// the newest first-parent commit whose declared edition protocol this CLI supports
|
|
@@ -74,6 +90,7 @@ import { runDevelopCoverage } from './develop-coverage-run.mjs';
|
|
|
74
90
|
import { CoverageExit } from './develop-coverage.mjs';
|
|
75
91
|
import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
|
|
76
92
|
import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
|
|
93
|
+
import { runSelectRecords } from './develop-select-records.mjs';
|
|
77
94
|
import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
78
95
|
import { EditionChannel } from './edition-constants.mjs';
|
|
79
96
|
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
|
|
@@ -162,6 +179,9 @@ async function run(argv, env) {
|
|
|
162
179
|
json: options['--json'] === true,
|
|
163
180
|
env,
|
|
164
181
|
});
|
|
182
|
+
case 'select-records':
|
|
183
|
+
await runSelectRecords({ options, env });
|
|
184
|
+
return;
|
|
165
185
|
case 'check-existing': {
|
|
166
186
|
if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
|
|
167
187
|
assertExistingArtifactMatches({
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// develop-record-ancestry — which verified record commit is the newest COMMIT on develop.
|
|
2
|
+
//
|
|
3
|
+
// "Newest" is the commit that equals or descends from every other candidate's commit, never the highest run:
|
|
4
|
+
// a re-run of an old commit carries a higher run number and must lose. History comes from git alone: one bare
|
|
5
|
+
// scratch repository per member, fetched blobless from the member's own URL through the same hold and credential
|
|
6
|
+
// environment freeze uses (`withSourceGitEnv`; no token on argv, none beyond the workspace's). Candidates are
|
|
7
|
+
// compared with each other only, never with a published pin.
|
|
8
|
+
//
|
|
9
|
+
// With a published commit for the member, the records may not take it backwards: behind or equal keeps the published
|
|
10
|
+
// commit (its own record must then exist), ahead selects the records' commit, otherwise the run is refused.
|
|
11
|
+
//
|
|
12
|
+
// Refusals (`RecordRefused`): a commit absent from the fetched develop history, two commits that are not
|
|
13
|
+
// ancestor-related. Operational (plain Error): git cannot be spawned, a fetch fails, or merge-base answers
|
|
14
|
+
// anything but 0 (ancestor) or 1 (not an ancestor).
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { withSourceGitEnv } from './git-source-auth.mjs';
|
|
22
|
+
import { RecordRefused } from './record-refused.mjs';
|
|
23
|
+
|
|
24
|
+
const DEVELOP_REF = 'refs/heads/develop';
|
|
25
|
+
// One external call to a git remote; a fetch that exceeds it fails naming the member, never a verdict.
|
|
26
|
+
const GIT_CALL_TIMEOUT_MS = 300_000;
|
|
27
|
+
const GIT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
export class IncomparableRecordCommitsError extends RecordRefused {
|
|
30
|
+
constructor(repo, first, second) {
|
|
31
|
+
super(repo, `record commits ${first.commit} (${first.tags.join(', ')}) and ${second.commit} (${second.tags.join(', ')}) are not ancestor-related on develop`);
|
|
32
|
+
this.name = 'IncomparableRecordCommitsError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RecordCommitNotOnDevelopError extends RecordRefused {
|
|
37
|
+
constructor(repo, commit, tags) {
|
|
38
|
+
super(repo, `record commit ${commit} (${tags.join(', ')}) is not in the fetched develop history`);
|
|
39
|
+
this.name = 'RecordCommitNotOnDevelopError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class DivergedFromPublishedError extends RecordRefused {
|
|
44
|
+
constructor(repo, selected, published) {
|
|
45
|
+
super(repo, `the newest record commit ${selected} and the published commit ${published} are not ancestor-related on develop`);
|
|
46
|
+
this.name = 'DivergedFromPublishedError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The published commit is newer than every record, and none of its own survives among the candidates. */
|
|
51
|
+
export class PublishedRecordMissingError extends RecordRefused {
|
|
52
|
+
constructor(repo, published) {
|
|
53
|
+
super(repo, `no verified build record exists for the published commit ${published}; it is newer than every candidate record`);
|
|
54
|
+
this.name = 'PublishedRecordMissingError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `{ status, stdout, stderr }` of one git run; a git that could not be spawned or timed out throws. */
|
|
59
|
+
export function defaultRunGit(args, { cwd, env }) {
|
|
60
|
+
const result = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: GIT_CALL_TIMEOUT_MS, maxBuffer: GIT_MAX_OUTPUT_BYTES });
|
|
61
|
+
if (result.error !== undefined || result.status === null) {
|
|
62
|
+
throw new Error(`git ${args[0]} could not run: ${result.error?.message ?? `killed by ${result.signal}`}`);
|
|
63
|
+
}
|
|
64
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} request
|
|
69
|
+
* @param {{repo: string, repositoryUrl: string}} request.member
|
|
70
|
+
* @param {{commit: string, tag: string}[]} request.candidates verified records' commits with their tags
|
|
71
|
+
* @param {string} [request.published] the commit already published for this member: kept when the records are not ahead of it
|
|
72
|
+
* @param {object} request.workspace the workspace config the source hold reads
|
|
73
|
+
* @param {object} request.env
|
|
74
|
+
* @param {Function} [request.runGit] (args, {cwd, env}) => {status, stdout, stderr}; replaced by tests
|
|
75
|
+
* @returns {string} the selected commit
|
|
76
|
+
*/
|
|
77
|
+
export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit }) {
|
|
78
|
+
const byCommit = new Map();
|
|
79
|
+
for (const { commit, tag } of candidates) {
|
|
80
|
+
byCommit.set(commit, [...(byCommit.get(commit) ?? []), tag]);
|
|
81
|
+
}
|
|
82
|
+
const commits = [...byCommit].map(([commit, tags]) => ({ commit, tags }));
|
|
83
|
+
const scratch = mkdtempSync(join(tmpdir(), 'xema-select-history-'));
|
|
84
|
+
try {
|
|
85
|
+
return withSourceGitEnv({ name: member.repo, url: member.repositoryUrl }, { workspace, env }, (gitEnv) => {
|
|
86
|
+
const git = (args, extraEnv = {}) => runGit(args, { cwd: scratch, env: { ...gitEnv, ...extraEnv } });
|
|
87
|
+
const init = git(['init', '--bare', '--quiet']);
|
|
88
|
+
if (init.status !== 0) {throw new Error(`select-records: ${member.repo}: git init failed: ${init.stderr.trim()}`);}
|
|
89
|
+
const fetched = git(['fetch', '--quiet', '--filter=blob:none', '--no-tags', member.repositoryUrl, `+${DEVELOP_REF}:${DEVELOP_REF}`]);
|
|
90
|
+
if (fetched.status !== 0) {
|
|
91
|
+
throw new Error(`select-records: ${member.repo}: git fetch of develop from ${member.repositoryUrl} failed (exit ${fetched.status}): ${fetched.stderr.trim()}`);
|
|
92
|
+
}
|
|
93
|
+
// 0 = ancestor (or equal), 1 = not; anything else is git failing, never a verdict
|
|
94
|
+
const isAncestor = (older, newer) => {
|
|
95
|
+
const result = git(['merge-base', '--is-ancestor', older, newer]);
|
|
96
|
+
if (result.status === 0) {return true;}
|
|
97
|
+
if (result.status === 1) {return false;}
|
|
98
|
+
throw new Error(`select-records: ${member.repo}: git merge-base ${older} ${newer} failed (exit ${result.status}): ${result.stderr.trim()}`);
|
|
99
|
+
};
|
|
100
|
+
const assertOnDevelop = (commit, tags) => {
|
|
101
|
+
// no lazy fetch: a commit the develop fetch did not bring is not on develop
|
|
102
|
+
const known = git(['cat-file', '-e', `${commit}^{commit}`], { GIT_NO_LAZY_FETCH: '1' });
|
|
103
|
+
if (known.status !== 0 || !isAncestor(commit, DEVELOP_REF)) {throw new RecordCommitNotOnDevelopError(member.repo, commit, tags);}
|
|
104
|
+
};
|
|
105
|
+
for (const { commit, tags } of commits) {assertOnDevelop(commit, tags);}
|
|
106
|
+
let selected = commits[0];
|
|
107
|
+
for (const other of commits.slice(1)) {
|
|
108
|
+
if (isAncestor(other.commit, selected.commit)) {continue;}
|
|
109
|
+
if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other);}
|
|
110
|
+
selected = other;
|
|
111
|
+
}
|
|
112
|
+
if (published === undefined) {return selected.commit;}
|
|
113
|
+
assertOnDevelop(published, ['published pin']);
|
|
114
|
+
if (isAncestor(selected.commit, published)) {return published;} // equal, or the records are behind it
|
|
115
|
+
if (isAncestor(published, selected.commit)) {return selected.commit;}
|
|
116
|
+
throw new DivergedFromPublishedError(member.repo, selected.commit, published);
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// develop-select-records — the body of `xema edition develop select-records`.
|
|
2
|
+
//
|
|
3
|
+
// For every member of the develop graph, take the newest signed build record the data root's record workflow
|
|
4
|
+
// published for the branch, and write the snapshot it names: the commit to pin, the run it came from, and the
|
|
5
|
+
// images it built. A record is trusted only after cosign verified it, offline against the pinned trusted root,
|
|
6
|
+
// as signed by the data root's `record-build.yaml` at `refs/heads/<branch>`, called by the member's own
|
|
7
|
+
// repository at the record's own commit.
|
|
8
|
+
//
|
|
9
|
+
// "Newest" is the newest COMMIT on develop, never the highest run: every record of the branch is read and
|
|
10
|
+
// verified, the selected commit must equal or descend from every other record's commit (git ancestry, develop
|
|
11
|
+
// fetched from the member), and among that commit's records (re-runs of it) the highest run and attempt wins.
|
|
12
|
+
// With --published-pins <file> (a file this verb wrote; only .pins is read) a member's published commit is kept
|
|
13
|
+
// when the records are not ahead of it, and a diverged one is refused; without it the rule applies among the
|
|
14
|
+
// records only (part 07 passes the flag).
|
|
15
|
+
//
|
|
16
|
+
// Every failure is a hard refusal naming the repository, and nothing is written. Two classes: `RecordRefused`
|
|
17
|
+
// (the record is missing, or cosign or the record itself says no) and a plain Error (the registry or cosign
|
|
18
|
+
// could not answer), which the operator retries rather than reads.
|
|
19
|
+
//
|
|
20
|
+
// Rollback residual: anyone who can delete registry tags can remove newer records, and select-records then pins
|
|
21
|
+
// the newest remaining valid one. The guard is deploy-latest's (part 07): its owner refuses a snapshot that goes
|
|
22
|
+
// backwards. This verb checks nothing against current pins.
|
|
23
|
+
|
|
24
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
29
|
+
import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
|
|
30
|
+
import { writeOnce } from './customer-edition-verbs.mjs';
|
|
31
|
+
import { basicAuthParts, defaultRunCosign } from './develop-coverage-readers.mjs';
|
|
32
|
+
import { developGraphMembers } from './develop-graph.mjs';
|
|
33
|
+
import { PublishedRecordMissingError, newestCommitOnDevelop } from './develop-record-ancestry.mjs';
|
|
34
|
+
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
35
|
+
import { dataRoot } from './edition-workspace.mjs';
|
|
36
|
+
import { loadDeclaredPinnedPolicy } from './evidence-trust-policy.mjs';
|
|
37
|
+
import { COSIGN_ENV, prepareTrustedCosignBinary, validatePinnedTrustedRoot } from './image-evidence.mjs';
|
|
38
|
+
import { compareStrings, loadKernelDistributionSchemas } from './lib.mjs';
|
|
39
|
+
import { RecordRefused } from './record-refused.mjs';
|
|
40
|
+
import { loadRegistryCredentialDeclarations, resolveRegistryCredential, splitImageRef } from './registry-credentials.mjs';
|
|
41
|
+
import { isNotPublishedYet } from './seal-image-digests.mjs';
|
|
42
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
43
|
+
import { assertPreparedExecutable } from './trusted-executable.mjs';
|
|
44
|
+
import { registryClientsFor } from './verify-image-platforms.mjs';
|
|
45
|
+
|
|
46
|
+
/** The repository segment, under the registry root, that holds a member's build records. */
|
|
47
|
+
export const BUILD_RECORDS_SEGMENT = 'build-records';
|
|
48
|
+
export const RECORD_SIGNER_WORKFLOW = '.github/workflows/record-build.yaml';
|
|
49
|
+
export const RECORD_SIGNER_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
|
|
50
|
+
/** The branches a record signer is declared for, each with the ref the record workflow runs at. */
|
|
51
|
+
const RECORD_SIGNER_REFS = Object.freeze({ develop: 'refs/heads/develop' });
|
|
52
|
+
/**
|
|
53
|
+
* A cosign exit whose stderr shows the registry was not reached before any verdict. Only this, a cosign that
|
|
54
|
+
* could not run, and a timeout are operational; every other non-zero exit of `cosign verify` is a verdict.
|
|
55
|
+
*/
|
|
56
|
+
const COSIGN_TRANSPORT_FAILURE = /dial tcp|i\/o timeout|connection (refused|reset)|no such host|TLS handshake timeout|context deadline exceeded/i;
|
|
57
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
58
|
+
|
|
59
|
+
/** The record's producer is a kind this CLI has no claims for: a closed set, never guessed at. */
|
|
60
|
+
export class UnknownBuildRecordProducerError extends RecordRefused {
|
|
61
|
+
constructor(repo, kind) {
|
|
62
|
+
super(repo, `the record's producer kind ${JSON.stringify(kind)} is not one select-records can verify`);
|
|
63
|
+
this.name = 'UnknownBuildRecordProducerError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class DataRootRepositoryUndeclaredError extends Error {
|
|
68
|
+
constructor() {
|
|
69
|
+
super('edition-workspace.json declares no dataRootRepositoryUrl; select-records cannot name the record signer');
|
|
70
|
+
this.name = 'DataRootRepositoryUndeclaredError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class KernelLacksBuildRecordSchemaError extends Error {
|
|
75
|
+
constructor(missing) {
|
|
76
|
+
super(`the kernel's distribution module does not export ${missing.join(', ')}; select-records needs a kernel with the build-record contract`);
|
|
77
|
+
this.name = 'KernelLacksBuildRecordSchemaError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The data root's `owner/name`, from the workspace declaration; refused by name when it declares none. */
|
|
82
|
+
export function declaredDataRootRepositoryUrl() {
|
|
83
|
+
const url = editionWorkspaceConfig().dataRootRepositoryUrl;
|
|
84
|
+
if (url === undefined) {throw new DataRootRepositoryUndeclaredError();}
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The kernel's build-record exports, refused as a set when any is absent. */
|
|
89
|
+
export function requireBuildRecordKernel(schemas) {
|
|
90
|
+
const names = ['BUILD_RECORD_MEDIA_TYPE', 'BuildRecordSchema', 'buildRecordTag', 'parseBuildRecordTag', 'compareBuildRecordRuns'];
|
|
91
|
+
const missing = names.filter((name) => schemas?.[name] === undefined);
|
|
92
|
+
if (missing.length > 0 || typeof schemas.BuildRecordSchema?.safeParse !== 'function') {
|
|
93
|
+
throw new KernelLacksBuildRecordSchemaError(missing.length > 0 ? missing : ['a BuildRecordSchema with safeParse']);
|
|
94
|
+
}
|
|
95
|
+
return schemas;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The ref and data-root repository a branch's records are signed by; refused before any network call. */
|
|
99
|
+
export function recordSignerOrigin({ branch, dataRootRepositoryUrl }) {
|
|
100
|
+
const ref = Object.hasOwn(RECORD_SIGNER_REFS, branch) ? RECORD_SIGNER_REFS[branch] : undefined;
|
|
101
|
+
if (ref === undefined) {throw new Error(`no record signer declared for branch ${branch}`);}
|
|
102
|
+
if (dataRootRepositoryUrl === undefined) {throw new DataRootRepositoryUndeclaredError();}
|
|
103
|
+
const dataRepository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(dataRootRepositoryUrl, 'dataRootRepositoryUrl'), 'dataRootRepositoryUrl');
|
|
104
|
+
return { ref, dataRepository };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The signer of the branch's records: the data root's record workflow at the branch, as the certificate states
|
|
109
|
+
* it, with the repository that CALLED the workflow (the member) as the workflow-repository claim.
|
|
110
|
+
*/
|
|
111
|
+
export function recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl }) {
|
|
112
|
+
const { ref, dataRepository } = recordSignerOrigin({ branch, dataRootRepositoryUrl });
|
|
113
|
+
const member = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(memberRepositoryUrl, 'repositoryUrl'), 'repositoryUrl');
|
|
114
|
+
return {
|
|
115
|
+
certificateIdentity: `https://github.com/${dataRepository}/${RECORD_SIGNER_WORKFLOW}@${ref}`,
|
|
116
|
+
oidcIssuer: RECORD_SIGNER_OIDC_ISSUER,
|
|
117
|
+
githubWorkflow: { repository: member, ref },
|
|
118
|
+
memberRepository: member,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a record's producer says, read through the ONE switch over its kind: the repository that called the
|
|
124
|
+
* record workflow (the cosign caller claim, held equal to the member's) and the run the snapshot names.
|
|
125
|
+
*/
|
|
126
|
+
export function producerClaims({ record, repo, memberRepository }) {
|
|
127
|
+
switch (record.producer?.kind) {
|
|
128
|
+
case 'github-actions': {
|
|
129
|
+
const { repository, runNumber, runAttempt } = record.producer;
|
|
130
|
+
if (repository !== memberRepository) {throw new RecordRefused(repo, `record is produced by repository ${repository}, not ${memberRepository}`);}
|
|
131
|
+
return { repository, snapshot: `${runNumber}.${runAttempt}` };
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
throw new UnknownBuildRecordProducerError(repo, record.producer?.kind);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Run one registry client call; ONLY a failure of the client itself is "the registry did not answer". */
|
|
139
|
+
async function fromRegistry(repo, what, call) {
|
|
140
|
+
try {
|
|
141
|
+
return await call();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new Error(`select-records: ${repo}: the registry did not answer for ${what}: ${error.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read one record by its tag: manifest by digest, its single layer, parsed against the kernel's schema. */
|
|
148
|
+
async function readRecord({ kernel, client, repository, repo, tag }) {
|
|
149
|
+
const served = await fromRegistry(repo, `${repository}:${tag}`, () => client.getManifestByTag(repository, tag));
|
|
150
|
+
const manifest = await fromRegistry(repo, `${repository}@${served.digest}`, () => client.getManifestByDigest(repository, served.digest));
|
|
151
|
+
const layers = manifest.document.layers;
|
|
152
|
+
if (!Array.isArray(layers) || layers.length !== 1) {
|
|
153
|
+
throw new RecordRefused(repo, `record ${tag} has ${Array.isArray(layers) ? layers.length : 'no'} layers, not exactly one`);
|
|
154
|
+
}
|
|
155
|
+
const [layer] = layers;
|
|
156
|
+
if (layer.mediaType !== kernel.BUILD_RECORD_MEDIA_TYPE) {
|
|
157
|
+
throw new RecordRefused(repo, `record ${tag} layer media type is ${JSON.stringify(layer.mediaType)}, not ${kernel.BUILD_RECORD_MEDIA_TYPE}`);
|
|
158
|
+
}
|
|
159
|
+
const bytes = await fromRegistry(repo, `${repository}@${layer.digest}`, () => client.getBlob(repository, layer.digest));
|
|
160
|
+
let document;
|
|
161
|
+
try {
|
|
162
|
+
document = JSON.parse(bytes.toString('utf8'));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new RecordRefused(repo, `record ${tag} is not JSON: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
const parsed = kernel.BuildRecordSchema.safeParse(document);
|
|
167
|
+
if (!parsed.success) {throw new RecordRefused(repo, `record ${tag} does not parse as a build record: ${parsed.error.message}`);}
|
|
168
|
+
return { record: parsed.data, digest: served.digest };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The tag's record, verified by digest against the constrained signer, then held to its own repo, branch and tag. */
|
|
172
|
+
async function loadVerifiedRecord({ kernel, client, runCosign, cosignEnv, trustedRootPath, repository, member, branch, dataRootRepositoryUrl, tag }) {
|
|
173
|
+
const repo = member.repo;
|
|
174
|
+
const { record, digest } = await readRecord({ kernel, client, repository, repo, tag });
|
|
175
|
+
const signer = recordSigner({ branch, dataRootRepositoryUrl, memberRepositoryUrl: member.repositoryUrl });
|
|
176
|
+
const { repository: callerRepository, snapshot } = producerClaims({ record, repo, memberRepository: signer.memberRepository });
|
|
177
|
+
signer.githubWorkflow.repository = callerRepository;
|
|
178
|
+
const ref = `${repository}@${digest}`;
|
|
179
|
+
const args = ['verify', '--offline', '--trusted-root', trustedRootPath, ...certificateConstraintArgs(signer, record.commit), ref];
|
|
180
|
+
let result;
|
|
181
|
+
try {
|
|
182
|
+
result = await runCosign(args, cosignEnv);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
// no numeric exit code: cosign did not run (spawn failure) or did not answer (timeout, kill signal)
|
|
185
|
+
throw new Error(`select-records: ${repo}: cosign could not run for ${ref}: ${error.message}`);
|
|
186
|
+
}
|
|
187
|
+
if (result.code !== 0) {
|
|
188
|
+
if (COSIGN_TRANSPORT_FAILURE.test(result.stderr)) {
|
|
189
|
+
throw new Error(`select-records: ${repo}: cosign could not reach the registry for ${ref} (exit ${result.code}): ${result.stderr.trim()}`);
|
|
190
|
+
}
|
|
191
|
+
throw new RecordRefused(repo, `cosign refused record ${tag} (${digest}) as signed by ${signer.certificateIdentity} (exit ${result.code}): ${result.stderr.trim()}`);
|
|
192
|
+
}
|
|
193
|
+
if (record.branch !== branch) {throw new RecordRefused(repo, `record ${tag} is for branch ${record.branch}, not ${branch}`);}
|
|
194
|
+
if (kernel.buildRecordTag(record) !== tag) {throw new RecordRefused(repo, `record tagged ${tag} says it is ${kernel.buildRecordTag(record)}`);}
|
|
195
|
+
return { record, snapshot };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {object} request
|
|
200
|
+
* @param {string} request.branch
|
|
201
|
+
* @param {string} request.registry the registry root, e.g. `registry.example.com/acme`
|
|
202
|
+
* @param {string} [request.at] ISO-8601 UTC: the newest record completed at or before it
|
|
203
|
+
* @param {readonly {repo: string, repositoryUrl: string}[]} request.members
|
|
204
|
+
* @param {string|undefined} request.dataRootRepositoryUrl
|
|
205
|
+
* @param {object} request.kernel the kernel's distribution exports
|
|
206
|
+
* @param {(authority: string) => object} request.clientFor registry client of an authority
|
|
207
|
+
* @param {(args: string[], env: object) => Promise<{code: number, stdout: string, stderr: string}>} request.runCosign
|
|
208
|
+
* @param {(authority: string) => object} request.cosignEnvFor
|
|
209
|
+
* @param {string} request.trustedRootPath
|
|
210
|
+
* @param {Record<string, string>} [request.publishedPins] member repo -> the commit already published (--published-pins)
|
|
211
|
+
* @param {object} request.workspace the workspace config the source hold reads
|
|
212
|
+
* @param {object} request.env
|
|
213
|
+
* @param {Function} [request.runGit] replaced by tests
|
|
214
|
+
* @returns {Promise<{pins: object, snapshot: object, images: object[]}>}
|
|
215
|
+
*/
|
|
216
|
+
export async function selectRecords({ branch, registry, at, members, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath, publishedPins, workspace, env, runGit }) {
|
|
217
|
+
requireBuildRecordKernel(kernel);
|
|
218
|
+
recordSignerOrigin({ branch, dataRootRepositoryUrl });
|
|
219
|
+
if (at !== undefined && (!ISO_UTC.test(at) || Number.isNaN(Date.parse(at)))) {
|
|
220
|
+
throw new Error(`--at must be an ISO-8601 UTC time (YYYY-MM-DDTHH:MM:SSZ); got ${JSON.stringify(at)}`);
|
|
221
|
+
}
|
|
222
|
+
const selected = [];
|
|
223
|
+
for (const member of members) {
|
|
224
|
+
const repository = `${registry}/${BUILD_RECORDS_SEGMENT}/${member.repo.split('/').at(-1)}`;
|
|
225
|
+
const { authority } = splitImageRef(repository);
|
|
226
|
+
const client = clientFor(authority);
|
|
227
|
+
let tags;
|
|
228
|
+
try {
|
|
229
|
+
tags = await client.listTags(repository);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (isNotPublishedYet(error)) {throw new RecordRefused(member.repo, `${repository} does not exist: no build record was ever published`);}
|
|
232
|
+
throw new Error(`select-records: ${member.repo}: the registry did not answer for ${repository} tags: ${error.message}`);
|
|
233
|
+
}
|
|
234
|
+
const runs = tags
|
|
235
|
+
.map((tag) => ({ tag, run: kernel.parseBuildRecordTag(tag, branch) }))
|
|
236
|
+
.filter(({ run }) => run !== undefined)
|
|
237
|
+
.sort((a, b) => kernel.compareBuildRecordRuns(b.run, a.run));
|
|
238
|
+
if (runs.length === 0) {throw new RecordRefused(member.repo, `${repository} has no build record for branch ${branch}`);}
|
|
239
|
+
// EVERY candidate is read and verified; any that fails refuses the run. None is skipped.
|
|
240
|
+
const verified = [];
|
|
241
|
+
for (const { tag, run } of runs) {
|
|
242
|
+
const loaded = await loadVerifiedRecord({
|
|
243
|
+
kernel, client, runCosign, cosignEnv: { ...COSIGN_ENV, ...cosignEnvFor(authority) }, trustedRootPath,
|
|
244
|
+
repository, member, branch, dataRootRepositoryUrl, tag,
|
|
245
|
+
});
|
|
246
|
+
verified.push({ tag, run, ...loaded });
|
|
247
|
+
}
|
|
248
|
+
const eligible = at === undefined ? verified : verified.filter(({ record }) => Date.parse(record.completedAt) <= Date.parse(at));
|
|
249
|
+
if (eligible.length === 0) {throw new RecordRefused(member.repo, `no build record for branch ${branch} completed at or before ${at}`);}
|
|
250
|
+
const published = publishedPins?.[member.repo];
|
|
251
|
+
const commit = newestCommitOnDevelop({
|
|
252
|
+
member, candidates: eligible.map(({ record, tag }) => ({ commit: record.commit, tag })), published, workspace, env, runGit,
|
|
253
|
+
});
|
|
254
|
+
// records of one commit are re-runs of it: the highest run, then attempt
|
|
255
|
+
const ofCommitIn = (pool) => pool.filter(({ record }) => record.commit === commit).sort((a, b) => kernel.compareBuildRecordRuns(b.run, a.run));
|
|
256
|
+
// a kept published commit may be one --at left out of the eligible records: its own verified record stands
|
|
257
|
+
const ofCommit = ofCommitIn(eligible).length > 0 ? ofCommitIn(eligible) : ofCommitIn(verified);
|
|
258
|
+
if (ofCommit.length === 0) {throw new PublishedRecordMissingError(member.repo, commit);}
|
|
259
|
+
const chosen = ofCommit[0];
|
|
260
|
+
selected.push({ member, ...chosen });
|
|
261
|
+
}
|
|
262
|
+
return composeSelection(selected);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The snapshot a selection writes. A name built by two records is refused: an image has one builder. */
|
|
266
|
+
export function composeSelection(selected) {
|
|
267
|
+
const pins = {};
|
|
268
|
+
const snapshot = {};
|
|
269
|
+
const owners = new Map();
|
|
270
|
+
const images = [];
|
|
271
|
+
for (const { member, record, snapshot: run } of selected) {
|
|
272
|
+
pins[member.repo] = record.commit;
|
|
273
|
+
snapshot[member.repo] = run;
|
|
274
|
+
for (const image of record.images) {
|
|
275
|
+
if (owners.has(image.name)) {
|
|
276
|
+
throw new RecordRefused(member.repo, `image ${image.name} is also built by ${owners.get(image.name)}; an image has one builder`);
|
|
277
|
+
}
|
|
278
|
+
owners.set(image.name, member.repo);
|
|
279
|
+
images.push({ name: image.name, digest: image.digest, contentHash: image.contentHash });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const sorted = (object) => Object.fromEntries(Object.entries(object).sort(([a], [b]) => compareStrings(a, b)));
|
|
283
|
+
return { pins: sorted(pins), snapshot: sorted(snapshot), images: images.sort((a, b) => compareStrings(a.name, b.name)) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The `pins` of a file this verb wrote: member repo -> 40-hex commit. Nothing else of the file is read. */
|
|
287
|
+
export function readPublishedPins(path) {
|
|
288
|
+
const document = readStrictJsonFile(path);
|
|
289
|
+
const { pins } = document ?? {};
|
|
290
|
+
if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
|
|
291
|
+
throw new Error(`--published-pins ${path} has no "pins" object`);
|
|
292
|
+
}
|
|
293
|
+
for (const [repo, commit] of Object.entries(pins)) {
|
|
294
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
295
|
+
throw new Error(`--published-pins ${path}: pin ${JSON.stringify(repo)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return pins;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The `select-records` verb. Writes `--out` once, only after every member was selected. */
|
|
302
|
+
export async function runSelectRecords({
|
|
303
|
+
options,
|
|
304
|
+
env = process.env,
|
|
305
|
+
loadPolicy = loadDeclaredPinnedPolicy,
|
|
306
|
+
prepareCosign = prepareTrustedCosignBinary,
|
|
307
|
+
loadKernel = loadKernelDistributionSchemas,
|
|
308
|
+
trustedRootBytes = (root, pinnedPolicy) => validatePinnedTrustedRoot(root, pinnedPolicy).bytes,
|
|
309
|
+
cosignPathOf = (handle) => assertPreparedExecutable(handle, 'cosign'),
|
|
310
|
+
members = developGraphMembers(),
|
|
311
|
+
runCosign: injectedRunCosign,
|
|
312
|
+
clientsOverride,
|
|
313
|
+
runGit,
|
|
314
|
+
write = writeOnce,
|
|
315
|
+
}) {
|
|
316
|
+
const dataRootRepositoryUrl = declaredDataRootRepositoryUrl();
|
|
317
|
+
const { policy, dataRoot: policyRoot } = await loadPolicy();
|
|
318
|
+
const kernel = requireBuildRecordKernel(await loadKernel());
|
|
319
|
+
const trustedCosign = prepareCosign({ cosignBinary: options['--cosign'], policy });
|
|
320
|
+
const cosignPath = cosignPathOf(trustedCosign);
|
|
321
|
+
const runCosign = injectedRunCosign ?? ((args, cosignEnv) => defaultRunCosign(cosignPath, args, cosignEnv));
|
|
322
|
+
const scratch = mkdtempSync(join(tmpdir(), 'xema-select-records-'));
|
|
323
|
+
try {
|
|
324
|
+
const trustedRootPath = join(scratch, 'trusted-root.json');
|
|
325
|
+
writeFileSync(trustedRootPath, trustedRootBytes(policyRoot, policy), { flag: 'wx', mode: 0o600 });
|
|
326
|
+
const declarations = clientsOverride === undefined ? loadRegistryCredentialDeclarations(dataRoot()) : null;
|
|
327
|
+
const clients = new Map();
|
|
328
|
+
const clientFor = (authority) => {
|
|
329
|
+
if (clientsOverride !== undefined) {return clientsOverride(authority);}
|
|
330
|
+
if (!clients.has(authority)) {
|
|
331
|
+
clients.set(authority, registryClientsFor([{ imageRef: `${authority}/x` }], { declarations, env }).get(authority));
|
|
332
|
+
}
|
|
333
|
+
return clients.get(authority);
|
|
334
|
+
};
|
|
335
|
+
// cosign gets the SAME declared credential oras uses, in its environment, never argv.
|
|
336
|
+
const cosignEnvFor = (authority) => {
|
|
337
|
+
if (clientsOverride !== undefined) {return {};}
|
|
338
|
+
const credential = resolveRegistryCredential({ authority, declarations, env });
|
|
339
|
+
if (credential.access === 'anonymous') {return {};}
|
|
340
|
+
const { username, password } = basicAuthParts(credential);
|
|
341
|
+
return { COSIGN_REGISTRY_USERNAME: username, COSIGN_REGISTRY_PASSWORD: password };
|
|
342
|
+
};
|
|
343
|
+
const selection = await selectRecords({
|
|
344
|
+
branch: options['--branch'],
|
|
345
|
+
registry: options['--registry'],
|
|
346
|
+
at: options['--at'],
|
|
347
|
+
members,
|
|
348
|
+
dataRootRepositoryUrl,
|
|
349
|
+
kernel,
|
|
350
|
+
clientFor,
|
|
351
|
+
runCosign,
|
|
352
|
+
cosignEnvFor,
|
|
353
|
+
trustedRootPath,
|
|
354
|
+
publishedPins: options['--published-pins'] === undefined ? undefined : readPublishedPins(options['--published-pins']),
|
|
355
|
+
workspace: editionWorkspaceConfig(),
|
|
356
|
+
env,
|
|
357
|
+
runGit,
|
|
358
|
+
});
|
|
359
|
+
write(options['--out'], selection);
|
|
360
|
+
return selection;
|
|
361
|
+
} finally {
|
|
362
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
363
|
+
trustedCosign.dispose();
|
|
364
|
+
}
|
|
365
|
+
}
|