@xemahq/edition-tooling 0.0.0-stage → 0.1.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.
Files changed (219) hide show
  1. package/LICENSE +175 -0
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +47 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/edition.d.ts +4 -0
  7. package/dist/commands/edition.d.ts.map +1 -0
  8. package/dist/commands/edition.js +58 -0
  9. package/dist/commands/edition.js.map +1 -0
  10. package/dist/edition/verify-image-evidence.bundle.mjs +72104 -0
  11. package/dist/edition/verify-image-evidence.bundle.provenance.json +4119 -0
  12. package/dist/lib/exit-codes.d.ts +6 -0
  13. package/dist/lib/exit-codes.d.ts.map +1 -0
  14. package/dist/lib/exit-codes.js +21 -0
  15. package/dist/lib/exit-codes.js.map +1 -0
  16. package/dist/runtime/edition/run-entry.d.ts +10 -0
  17. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  18. package/dist/runtime/edition/run-entry.js +49 -0
  19. package/dist/runtime/edition/run-entry.js.map +1 -0
  20. package/dist/runtime/edition/verb-map.d.ts +70 -0
  21. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  22. package/dist/runtime/edition/verb-map.js +166 -0
  23. package/dist/runtime/edition/verb-map.js.map +1 -0
  24. package/edition/README.md +291 -0
  25. package/edition/adapter-bundled-versions.mjs +128 -0
  26. package/edition/adapter-host-versions.mjs +76 -0
  27. package/edition/assemble-producer-input.mjs +1423 -0
  28. package/edition/assemble-third-party-input.mjs +348 -0
  29. package/edition/authored-source-check.mjs +141 -0
  30. package/edition/authority-binding.mjs +23 -0
  31. package/edition/authorization-base.mjs +357 -0
  32. package/edition/biome-content.mjs +327 -0
  33. package/edition/build-biome-index.mjs +180 -0
  34. package/edition/build-ledger-completeness.mjs +67 -0
  35. package/edition/build-ledger.mjs +107 -0
  36. package/edition/build-platform-service-index.mjs +292 -0
  37. package/edition/build-runtime-image-source-index.mjs +1197 -0
  38. package/edition/built-verifier.mjs +55 -0
  39. package/edition/candidate-edition.mjs +735 -0
  40. package/edition/canonical-evidence-json.mjs +82 -0
  41. package/edition/canonical-repository-url.mjs +61 -0
  42. package/edition/catalogue-edition.mjs +49 -0
  43. package/edition/catalogue-evidence.mjs +228 -0
  44. package/edition/catalogue-fetch.mjs +124 -0
  45. package/edition/catalogue-lock-field.mjs +41 -0
  46. package/edition/catalogue-pin.mjs +71 -0
  47. package/edition/catalogue.mjs +237 -0
  48. package/edition/chart-set-source-commit.mjs +97 -0
  49. package/edition/check-committed-image-evidence.mjs +412 -0
  50. package/edition/checkout-indexed-source-repositories.mjs +283 -0
  51. package/edition/collected-refusals.mjs +59 -0
  52. package/edition/comment-blanker.mjs +75 -0
  53. package/edition/connector-source-authority.mjs +60 -0
  54. package/edition/contract-authority.mjs +122 -0
  55. package/edition/cosign-certificate-constraints.mjs +50 -0
  56. package/edition/cross-repo-build-inputs.mjs +158 -0
  57. package/edition/customer-definition.mjs +72 -0
  58. package/edition/customer-edition-verbs.mjs +610 -0
  59. package/edition/customer-flags.mjs +15 -0
  60. package/edition/definition-checkout.mjs +96 -0
  61. package/edition/develop-build-record.mjs +122 -0
  62. package/edition/develop-catalogue-free-resolve.mjs +282 -0
  63. package/edition/develop-catalogue-free-vendor.mjs +118 -0
  64. package/edition/develop-channel-evidence.mjs +23 -0
  65. package/edition/develop-channel-record.mjs +49 -0
  66. package/edition/develop-channel-satellites.mjs +65 -0
  67. package/edition/develop-chart-roster.mjs +96 -0
  68. package/edition/develop-chart-set.mjs +147 -0
  69. package/edition/develop-current-resolution.mjs +29 -0
  70. package/edition/develop-edition-invariants.mjs +142 -0
  71. package/edition/develop-edition-resolve.mjs +250 -0
  72. package/edition/develop-edition.mjs +332 -0
  73. package/edition/develop-engine-image.mjs +97 -0
  74. package/edition/develop-freeze.mjs +304 -0
  75. package/edition/develop-git-bundles.mjs +296 -0
  76. package/edition/develop-graph.mjs +34 -0
  77. package/edition/develop-image-coordinate.mjs +85 -0
  78. package/edition/develop-image-probe.mjs +122 -0
  79. package/edition/develop-image-record.mjs +260 -0
  80. package/edition/develop-resolve-report.mjs +159 -0
  81. package/edition/develop-select-records.mjs +90 -0
  82. package/edition/develop-selection-line.mjs +29 -0
  83. package/edition/develop-selection-pins.mjs +36 -0
  84. package/edition/develop-snapshot-inputs.mjs +112 -0
  85. package/edition/develop-store-root.mjs +20 -0
  86. package/edition/dispatched-runtime-components.mjs +172 -0
  87. package/edition/dist-check.mjs +1174 -0
  88. package/edition/distribution-evidence-set.mjs +1064 -0
  89. package/edition/distribution-paths.mjs +191 -0
  90. package/edition/edition-artifact.mjs +529 -0
  91. package/edition/edition-biome-manifests.mjs +85 -0
  92. package/edition/edition-ceiling.mjs +182 -0
  93. package/edition/edition-channel.mjs +27 -0
  94. package/edition/edition-chart-set.mjs +104 -0
  95. package/edition/edition-config.mjs +374 -0
  96. package/edition/edition-constants.mjs +110 -0
  97. package/edition/edition-contract-parse.mjs +179 -0
  98. package/edition/edition-contract.json +100 -0
  99. package/edition/edition-image-seal.mjs +115 -0
  100. package/edition/edition-kind.mjs +10 -0
  101. package/edition/edition-list.mjs +35 -0
  102. package/edition/edition-locks.mjs +78 -0
  103. package/edition/edition-pin-verbs.mjs +183 -0
  104. package/edition/edition-pin.mjs +17 -0
  105. package/edition/edition-pinned-inputs.mjs +116 -0
  106. package/edition/edition-protocol.mjs +285 -0
  107. package/edition/edition-publisher.mjs +21 -0
  108. package/edition/edition-seal.mjs +257 -0
  109. package/edition/edition-source-pins.mjs +211 -0
  110. package/edition/edition-unpin.mjs +16 -0
  111. package/edition/edition-workspace.mjs +205 -0
  112. package/edition/evidence-checkout.mjs +439 -0
  113. package/edition/evidence-grammars.mjs +18 -0
  114. package/edition/evidence-input-mode.mjs +136 -0
  115. package/edition/evidence-trust-pin.mjs +392 -0
  116. package/edition/evidence-trust-policy.mjs +374 -0
  117. package/edition/external-source-walk.mjs +486 -0
  118. package/edition/first-party-source-ids.mjs +62 -0
  119. package/edition/fleet-coordinate-finality.mjs +363 -0
  120. package/edition/fulcio-certificate-extensions.mjs +278 -0
  121. package/edition/git-json.mjs +54 -0
  122. package/edition/git-source-auth.mjs +131 -0
  123. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  124. package/edition/image-evidence-replay-state.mjs +242 -0
  125. package/edition/image-evidence.mjs +1328 -0
  126. package/edition/image-lock-service-rows.mjs +26 -0
  127. package/edition/image-subject-identity.mjs +19 -0
  128. package/edition/images-from-record.mjs +216 -0
  129. package/edition/indexes-at-pins.mjs +13 -0
  130. package/edition/infra-images-lock.mjs +105 -0
  131. package/edition/kernel-capability-domain.mjs +30 -0
  132. package/edition/kernel-contract-authority.mjs +526 -0
  133. package/edition/kernel-contract-source-path-set.mjs +96 -0
  134. package/edition/kernel-package.mjs +237 -0
  135. package/edition/kernel-pin-source.mjs +63 -0
  136. package/edition/kernel-protocol-choice.mjs +239 -0
  137. package/edition/kernel-signer-kind.mjs +23 -0
  138. package/edition/lib.mjs +3322 -0
  139. package/edition/locked-bundle-signer.mjs +297 -0
  140. package/edition/map-with-concurrency.mjs +19 -0
  141. package/edition/no-follow-file.mjs +94 -0
  142. package/edition/oci-evidence-verbs.mjs +421 -0
  143. package/edition/oci-image-source-evidence.mjs +377 -0
  144. package/edition/operation-grant-admission.mjs +285 -0
  145. package/edition/operation-requirements.mjs +282 -0
  146. package/edition/oras-process.mjs +35 -0
  147. package/edition/oras.mjs +664 -0
  148. package/edition/pin-source.mjs +242 -0
  149. package/edition/pinned-cosign.mjs +86 -0
  150. package/edition/plan-image-evidence-refresh.mjs +428 -0
  151. package/edition/platform-lock-readability.mjs +903 -0
  152. package/edition/platform-service-class.mjs +85 -0
  153. package/edition/platform-service-ownership.mjs +91 -0
  154. package/edition/platform-service-source-authority.mjs +105 -0
  155. package/edition/positive-closure.mjs +162 -0
  156. package/edition/previous-image-lock.mjs +25 -0
  157. package/edition/produce-distribution-completeness-evidence.mjs +317 -0
  158. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  159. package/edition/produce-image-delivery-entitlement-evidence.mjs +302 -0
  160. package/edition/produce-image-source-evidence.mjs +788 -0
  161. package/edition/producer-evidence-bundle.mjs +201 -0
  162. package/edition/producer-evidence-index-file.mjs +4 -0
  163. package/edition/record-refused.mjs +11 -0
  164. package/edition/registry-client-factory.mjs +21 -0
  165. package/edition/registry-credentials.mjs +225 -0
  166. package/edition/release-catalogue.mjs +48 -0
  167. package/edition/release-channel-guard.mjs +108 -0
  168. package/edition/release-edition-artifact.mjs +152 -0
  169. package/edition/release-edition.mjs +54 -0
  170. package/edition/remedy-command.mjs +89 -0
  171. package/edition/repin-evidence-trust.mjs +109 -0
  172. package/edition/required-resource-provider.mjs +58 -0
  173. package/edition/resolve-chart-set.mjs +376 -0
  174. package/edition/resolve-customer-edition.mjs +374 -0
  175. package/edition/resolve-distribution.mjs +2642 -0
  176. package/edition/resolve-edition.mjs +192 -0
  177. package/edition/resolve-images.mjs +2344 -0
  178. package/edition/resolve-infra-images.mjs +88 -0
  179. package/edition/seal-image-digests.mjs +954 -0
  180. package/edition/sealed-distribution-lock.mjs +22 -0
  181. package/edition/sealed-evidence-accept.mjs +82 -0
  182. package/edition/shallow-checkout.mjs +18 -0
  183. package/edition/sigstore-bundle-media-type.mjs +4 -0
  184. package/edition/source-authority.mjs +713 -0
  185. package/edition/source-closure.mjs +115 -0
  186. package/edition/store-publisher-trust.mjs +40 -0
  187. package/edition/strict-json.mjs +121 -0
  188. package/edition/string-order.mjs +5 -0
  189. package/edition/third-party-admission.mjs +118 -0
  190. package/edition/third-party-bundle-registry.mjs +349 -0
  191. package/edition/third-party-certificate-extensions.mjs +58 -0
  192. package/edition/third-party-image-pins.mjs +41 -0
  193. package/edition/third-party-pins-trusted.mjs +24 -0
  194. package/edition/third-party-producer-evidence.mjs +76 -0
  195. package/edition/third-party-source-check.mjs +28 -0
  196. package/edition/third-party-source-context.mjs +41 -0
  197. package/edition/third-party-source-evidence.mjs +326 -0
  198. package/edition/third-party-source-flags.mjs +148 -0
  199. package/edition/third-party-source-pins.mjs +270 -0
  200. package/edition/third-party-source-uniqueness.mjs +54 -0
  201. package/edition/third-party-source-walk.mjs +118 -0
  202. package/edition/third-party-source-writer.mjs +228 -0
  203. package/edition/third-party-subjects.mjs +128 -0
  204. package/edition/trust-policy-arming-verb.mjs +60 -0
  205. package/edition/trust-policy-arming.mjs +144 -0
  206. package/edition/trust-policy-image-ownership.mjs +85 -0
  207. package/edition/trusted-executable.mjs +166 -0
  208. package/edition/verifier-binding.mjs +45 -0
  209. package/edition/verifier-digest-pin.mjs +17 -0
  210. package/edition/verifier-files.mjs +8 -0
  211. package/edition/verifier-kernel-schemas.mjs +22 -0
  212. package/edition/verifier-provenance.mjs +64 -0
  213. package/edition/verify-catalogue-evidence.mjs +247 -0
  214. package/edition/verify-image-evidence.mjs +493 -0
  215. package/edition/verify-image-platforms.mjs +523 -0
  216. package/edition/why.mjs +56 -0
  217. package/edition/workspace-sections.mjs +287 -0
  218. package/package.json +83 -4
  219. package/README.md +0 -3
@@ -0,0 +1,228 @@
1
+ // The trusted writer of a third-party source pin (XEMA-1611 design section 3): `xema-edition index runtime-image-source
2
+ // --trusted-write --third-party <name> ...`. It PLANS the write: reads the bundle and the evidence from the registry,
3
+ // runs checks (a) to (e) on the exact bytes it would commit, and returns the files, the pin record and the candidate
4
+ // index. `commitThirdPartySourceWrite` then writes them. The third-party repository is never cloned.
5
+
6
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ import { EvidenceSubjectKind, isAuthorityBound } from './authority-binding.mjs';
11
+ import { REPIN_COMMAND } from './evidence-trust-pin.mjs';
12
+ import { canonicalJsonBytes, evidenceDirectoryForDigest, sha256Digest } from './image-evidence.mjs';
13
+ import { resolveImageSourceEvidenceReferrer } from './oci-image-source-evidence.mjs';
14
+ import { PRODUCER_EVIDENCE_INDEX_FILE } from './producer-evidence-index-file.mjs';
15
+ import { splitImageRef } from './registry-credentials.mjs';
16
+ import { bundleImageNames, fetchBundleManifest, fetchClassicProvenanceBundle, verifyBundleOnRegistry } from './third-party-bundle-registry.mjs';
17
+ import { declaredRunnerEnvironment } from './third-party-certificate-extensions.mjs';
18
+ import {
19
+ PROVENANCE_BUNDLE_FILE,
20
+ PROVENANCE_STATEMENT_FILE,
21
+ THIRD_PARTY_EVIDENCE_CHANNEL,
22
+ bundleSignerRowFor,
23
+ digestOfOciRef,
24
+ provenanceDirectoryForDigest,
25
+ soleThirdPartyImageRow,
26
+ thirdPartySignerRow,
27
+ verifyThirdPartySourceEvidence,
28
+ } from './third-party-source-evidence.mjs';
29
+ import { THIRD_PARTY_SOURCE_PINS_FILE, THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION, loadThirdPartySourcePins } from './third-party-source-pins.mjs';
30
+ import { assertThirdPartyUniqueness } from './third-party-source-uniqueness.mjs';
31
+
32
+ const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
33
+
34
+ async function collectImage({ image, request, context, clientFor, services, channel }) {
35
+ const { schemas, expectedContractAuthority, policy } = context;
36
+ const label = `image ${image.imageRef}@${image.manifestDigest}`;
37
+ const row = soleThirdPartyImageRow({ policy, source: { repository: request.repository }, imageRef: image.imageRef, schemas, label });
38
+ const { authority } = splitImageRef(image.imageRef);
39
+ const skipped = [];
40
+ const evidence = await resolveImageSourceEvidenceReferrer({
41
+ client: clientFor(authority),
42
+ imageRepository: image.imageRef,
43
+ subjectDigest: image.manifestDigest,
44
+ schemas,
45
+ expectedContractAuthority,
46
+ authorityBound: isAuthorityBound(channel, EvidenceSubjectKind.ThirdParty),
47
+ onInvalidReferrer: (report) => skipped.push(`${report.artifactDigest}: ${report.reason}`),
48
+ });
49
+ if (evidence === null) {
50
+ throw new Error(`${label} has no valid image-source evidence referrer${skipped.length === 0 ? '' : ` (skipped: ${skipped.join('; ')})`}`);
51
+ }
52
+ if (evidence.records.length !== 1) {
53
+ throw new Error(`${label}: the evidence referrer carries ${evidence.records.length} statements; a third-party image has exactly one`);
54
+ }
55
+ const [record] = evidence.records;
56
+ const { source } = record.statement.predicate;
57
+ if (!services.has(source.id)) {
58
+ throw new Error(`${label}: its statement attests ${source.kind}:${source.id}, which is not an oci-image service of bundle ${request.bundleOciRef} (${[...services].join(', ')})`);
59
+ }
60
+ const directory = evidenceDirectoryForDigest(image.manifestDigest);
61
+ return {
62
+ pin: {
63
+ subjectKind: source.kind,
64
+ subjectId: source.id,
65
+ imageRef: image.imageRef,
66
+ manifestDigest: image.manifestDigest,
67
+ sourceTreeHash: source.sourceTreeHash,
68
+ sourceHashAlgorithm: source.sourceHashAlgorithm,
69
+ statementDigest: record.descriptor.statementDigest,
70
+ bundleDigest: record.descriptor.bundleDigest,
71
+ policyId: row.policyId,
72
+ producerWorkflow: row.githubWorkflow.path,
73
+ },
74
+ files: [
75
+ [`${directory}/${PRODUCER_EVIDENCE_INDEX_FILE}`, evidence.indexBytes],
76
+ [`${directory}/${record.descriptor.statementPath}`, record.statementBytes],
77
+ [`${directory}/${record.descriptor.bundlePath}`, record.bundleBytes],
78
+ ],
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Plan the write of one third-party source. Throws on the first violated check; writes nothing to the data root.
84
+ *
85
+ * @param {object} options
86
+ * @param {{name, repository, repositoryId, repositoryOwnerId, sourceCommit, bundleOciRef, biomeId, runnerEnvironment?: 'self-hosted', images: {imageRef, manifestDigest}[]}} options.request
87
+ * @param {object} options.context `loadThirdPartySourceContext`
88
+ * @param {(authority: string) => object} options.clientFor the registry client of an authority
89
+ * @param {(request: {committedIndex: object, pin: {repository: string, workflowCommit: string}}) => object} options.withProducerWorkflowPin
90
+ * the committed runtime image source index with the pin merged in, built by the index generator
91
+ * @param {string} options.dataRoot
92
+ * @param {string} options.channel the `EditionChannel` the pin is written for; develop does not hold a third-party statement to the contract authority
93
+ * @param {(options: object) => void} [options.verifyOnRegistry]
94
+ */
95
+ export async function planThirdPartySourceWrite({ request, context, clientFor, withProducerWorkflowPin, dataRoot, channel, verifyOnRegistry = verifyBundleOnRegistry }) {
96
+ const { policy, schemas } = context;
97
+ const repositoryUrl = `https://github.com/${request.repository}`;
98
+ const row = bundleSignerRowFor({ policy, repository: request.repository, channel: THIRD_PARTY_EVIDENCE_CHANNEL, schemas, label: `third-party source "${request.name}" bundle signer` });
99
+ const signerWorkflow = row.githubWorkflow.path;
100
+ // The row must be exact (release channel, refs/heads/main, the GitHub issuer, its own workflow identity) before cosign runs.
101
+ thirdPartySignerRow({
102
+ policy,
103
+ policyId: row.policyId,
104
+ source: { repository: request.repository },
105
+ workflowPath: signerWorkflow,
106
+ schemas,
107
+ label: `third-party source "${request.name}" bundle signer`,
108
+ });
109
+
110
+ // (a) the bundle: the registry's signature, then its provenance attestation, under exactly this row; the provenance
111
+ // statement is the one cosign verified, never a registry read. Then the bundle bytes.
112
+ const { provenance: verifiedProvenance } = verifyOnRegistry({
113
+ trustedCosign: context.trustedCosign,
114
+ policy,
115
+ policyRoot: context.policyRoot,
116
+ row,
117
+ workflowSha: request.sourceCommit,
118
+ ociRef: request.bundleOciRef,
119
+ });
120
+ const { manifest } = await fetchBundleManifest({ clientFor, ociRef: request.bundleOciRef });
121
+ if (manifest?.xema?.id !== request.biomeId) {
122
+ throw new Error(`bundle ${request.bundleOciRef} declares xema.id ${JSON.stringify(manifest?.xema?.id)}, but --biome-id is ${JSON.stringify(request.biomeId)}`);
123
+ }
124
+ const sourceManifestHash = await schemas.computeBiomeSourceManifestHash(manifest);
125
+ const services = bundleImageNames(manifest);
126
+ // The verified envelope's certificate and tlog entry, as an offline-verifiable bundle; held below by the same offline
127
+ // verification `--check-third-party` repeats before a byte is committed.
128
+ const provenance = {
129
+ statementBytes: verifiedProvenance.statementBytes,
130
+ bundleBytes: await fetchClassicProvenanceBundle({ clientFor, ociRef: request.bundleOciRef, envelope: verifiedProvenance.envelope }),
131
+ };
132
+ const provenanceDirectory = provenanceDirectoryForDigest(digestOfOciRef(request.bundleOciRef));
133
+
134
+ // (b) each image's image-source/v2 evidence, as an OCI referrer of imageRef@digest.
135
+ const images = [];
136
+ const files = [
137
+ [`${provenanceDirectory}/${PROVENANCE_STATEMENT_FILE}`, provenance.statementBytes],
138
+ [`${provenanceDirectory}/${PROVENANCE_BUNDLE_FILE}`, provenance.bundleBytes],
139
+ ];
140
+ for (const image of request.images) {
141
+ const collected = await collectImage({ image, request, context, clientFor, services, channel });
142
+ images.push(collected.pin);
143
+ files.push(...collected.files);
144
+ }
145
+
146
+ const source = {
147
+ name: request.name,
148
+ repository: request.repository,
149
+ repositoryUrl,
150
+ repositoryId: request.repositoryId,
151
+ repositoryOwnerId: request.repositoryOwnerId,
152
+ sourceCommit: request.sourceCommit,
153
+ ...(request.runnerEnvironment === undefined ? {} : { runnerEnvironment: request.runnerEnvironment }),
154
+ bundle: {
155
+ ociRef: request.bundleOciRef,
156
+ biomeId: request.biomeId,
157
+ sourceManifestHash,
158
+ signerPolicyId: row.policyId,
159
+ signerWorkflow,
160
+ statementDigest: sha256Digest(provenance.statementBytes),
161
+ bundleDigest: sha256Digest(provenance.bundleBytes),
162
+ },
163
+ images: images.sort((left, right) => compare(left.imageRef, right.imageRef)),
164
+ };
165
+ const committed = loadThirdPartySourcePins(dataRoot).sources.filter((candidate) => candidate.name !== request.name);
166
+ const sources = [...committed, source].sort((left, right) => compare(left.name, right.name));
167
+
168
+ // (e) uniqueness, over the pins as they would be committed.
169
+ assertThirdPartyUniqueness({ sources, policy, runtimeSourceIndex: context.runtimeSourceIndex, biomeIndex: context.biomeIndex, platformServiceIndex: context.platformServiceIndex });
170
+
171
+ // (a) offline over the exact bytes to commit, (b) to (d): the very verification `--check-third-party` repeats.
172
+ const candidateIndex = schemas.parseRuntimeImageSourceIndex(
173
+ withProducerWorkflowPin({ committedIndex: context.runtimeSourceIndex, pin: { repository: request.repository, workflowCommit: request.sourceCommit } }),
174
+ );
175
+ const staging = mkdtempSync(join(tmpdir(), 'xema-third-party-write-'));
176
+ try {
177
+ for (const [path, bytes] of files) {
178
+ const target = join(staging, path);
179
+ mkdirSync(dirname(target), { recursive: true });
180
+ writeFileSync(target, bytes, { flag: 'wx' });
181
+ }
182
+ verifyThirdPartySourceEvidence({ source, evidenceRoot: staging, context: { ...context, runtimeSourceIndex: candidateIndex }, channel });
183
+ } finally {
184
+ rmSync(staging, { recursive: true, force: true });
185
+ }
186
+ return { source, sources, files, candidateIndex };
187
+ }
188
+
189
+ function writeNewOrIdentical(dataRoot, path, bytes) {
190
+ const target = join(dataRoot, path);
191
+ try {
192
+ writeFileSync(target, bytes, { flag: 'wx', mode: 0o644 });
193
+ } catch (error) {
194
+ if (error.code !== 'EEXIST') {throw error;}
195
+ if (!readFileSync(target).equals(bytes)) {
196
+ throw new Error(`${path} already exists with different bytes; evidence is content-addressed and is never overwritten`);
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Write a planned source: the evidence files (content-addressed, never overwritten), then the pin file, then the
203
+ * index. The evidence trust pin is NOT repinned: reviewing the pin file and running `xema-edition repin-evidence-trust`
204
+ * is the intended next step, and until then the pin load refuses.
205
+ *
206
+ * @returns {string} the next step to print
207
+ */
208
+ export function commitThirdPartySourceWrite({ dataRoot, plan, indexPath, indexText }) {
209
+ for (const [path, bytes] of plan.files) {
210
+ mkdirSync(dirname(join(dataRoot, path)), { recursive: true });
211
+ writeNewOrIdentical(dataRoot, path, bytes);
212
+ }
213
+ writeFileSync(
214
+ join(dataRoot, THIRD_PARTY_SOURCE_PINS_FILE),
215
+ canonicalJsonBytes({ schemaVersion: THIRD_PARTY_SOURCE_PINS_SCHEMA_VERSION, sources: plan.sources }),
216
+ );
217
+ writeFileSync(indexPath, indexText);
218
+ const runner = declaredRunnerEnvironment(plan.source);
219
+ const runnerDeclaration =
220
+ plan.source.runnerEnvironment === undefined
221
+ ? `runnerEnvironment ${runner} (undeclared)`
222
+ : `runnerEnvironment ${runner} (DECLARED: the reviewer must verify, at ${plan.source.sourceCommit}, that every signer workflow of ` +
223
+ `${plan.source.repository} pins runs-on to the dedicated signing runner label; no check here reads runs-on)`;
224
+ return (
225
+ `pinned third-party source "${plan.source.name}" (${plan.source.repository}@${plan.source.sourceCommit}, ${plan.source.images.length} image(s), ${runnerDeclaration}); ` +
226
+ `run \`${REPIN_COMMAND}\` after reviewing ${THIRD_PARTY_SOURCE_PINS_FILE}: the evidence trust pin does not yet record it, so the pin load refuses until then`
227
+ );
228
+ }
@@ -0,0 +1,128 @@
1
+ // Third-party OCI biomes on the resolve side (XEMA-1611 design section 4).
2
+ //
3
+ // A biome from an organisation outside the vendor fleet reaches an edition as a lock row with NO `source` (the lock's
4
+ // `LockedBiomeSource` is git-only), an OCI `bundleSource` and `origin: third-party`. That triple is the whole key; the
5
+ // pin file (`third-party-source-pins.json`, written only by the trusted writer) is what the row is then held to. The
6
+ // image subjects of such a row take `imageRef`, `sourceCommit` and `contentHash` from the pin and are never built,
7
+ // hashed or probed by this plane: there is no checkout to hash.
8
+ //
9
+ // `contentHash` of such a subject is the PUBLISHER'S statement (`sourceTreeHash` of its image-source evidence), never
10
+ // a hash this plane computed. It is therefore labelled `contentHash (publisher-attested)` wherever it is printed and
11
+ // is not used as an image tag, a cache key or a reproducibility check by any path that serves third-party subjects.
12
+
13
+ import { loadKernelBiomeContracts } from './lib.mjs';
14
+
15
+ export const PUBLISHER_ATTESTED_LABEL = 'publisher-attested';
16
+
17
+ export const OCI_BUNDLE_SOURCE_KIND = 'oci';
18
+
19
+ const hasOciBundleSource = (row) => row.bundleSource?.kind === OCI_BUNDLE_SOURCE_KIND;
20
+ const isThirdPartyOrigin = (row) => row.origin === loadKernelBiomeContracts().BiomeOrigin.ThirdParty;
21
+
22
+ /**
23
+ * The lock key of a third-party OCI biome: no `source`, an OCI `bundleSource`, `origin: third-party`.
24
+ * A lock row and a walked index entry both carry these fields; an index entry's `source` is the walk's stamp, so it
25
+ * is dropped by the caller that tests an entry (`isThirdPartyOciEntry`).
26
+ */
27
+ export const isThirdPartyOciRow = (row) => row.source === undefined && hasOciBundleSource(row) && isThirdPartyOrigin(row);
28
+
29
+ /** A walked index entry that is a third-party OCI biome: its `source` is the walk's stamp, not a lock source. */
30
+ export const isThirdPartyOciEntry = (entry) => hasOciBundleSource(entry) && isThirdPartyOrigin(entry);
31
+
32
+ /**
33
+ * Refuse an OCI `bundleSource` on a row whose origin is not third-party: only a third-party biome is pulled from an OCI
34
+ * bundle by digest, so the vendor's origin on such a row would place an unevidenced bundle in the vendor's trust domain.
35
+ * A row drawn from a git `source` is not this key and carries no OCI `bundleSource` at all: resolve omits the manifest's
36
+ * (it names a release artifact not built from the pinned commit), and a lock stating one is refused (`locked-bundle-signer.mjs`).
37
+ */
38
+ export function assertOciBundleSourceOrigin(row, label) {
39
+ if (row.source === undefined && hasOciBundleSource(row) && !isThirdPartyOrigin(row)) {
40
+ throw new Error(
41
+ `${label} carries an OCI bundleSource (${row.bundleSource.ociRef}) but its origin is "${row.origin}": an OCI ` +
42
+ `biome bundle is admitted only as origin "${loadKernelBiomeContracts().BiomeOrigin.ThirdParty}", with a pin of the data root's third-party source pins`,
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The third-party source pin a third-party OCI row is held to, by the row's `repo` (the pinned repository). Refuses a
49
+ * row with no pin (naming why the data root has none when it has none), a pin for another biome, another bundle
50
+ * reference or another manifest hash, and a pin the distribution does not declare as an `oci` source of that name.
51
+ *
52
+ * @param {{row: object, declaredSources: readonly object[]|undefined, pins: {sources: readonly object[], reason: string|null, byRepository: (repository: string) => object|undefined}|undefined, label: string}} options
53
+ */
54
+ export function thirdPartyPinForRow({ row, pins, declaredSources, label }) {
55
+ if (pins === undefined) {
56
+ throw new Error(`${label} is a third-party OCI biome, but this resolve was given no third-party source pins to hold it to`);
57
+ }
58
+ const pin = pins.byRepository(row.repo);
59
+ if (pin === undefined) {
60
+ throw new Error(
61
+ `${label} is a third-party OCI biome with neither a source nor a pin: ${pins.reason ?? `no third-party source pin names repository "${row.repo}"`}`,
62
+ );
63
+ }
64
+ if (row.id !== pin.bundle.biomeId) {
65
+ throw new Error(`${label} has biome id "${row.id}", but the third-party pin "${pin.name}" is for biome "${pin.bundle.biomeId}"`);
66
+ }
67
+ if (row.bundleSource.ociRef !== pin.bundle.ociRef) {
68
+ throw new Error(`${label} names bundle ${row.bundleSource.ociRef}, but the third-party pin "${pin.name}" pins ${pin.bundle.ociRef}`);
69
+ }
70
+ if (row.sourceManifestHash !== pin.bundle.sourceManifestHash) {
71
+ throw new Error(
72
+ `${label} has sourceManifestHash ${row.sourceManifestHash}, but the third-party pin "${pin.name}" pins ${pin.bundle.sourceManifestHash}`,
73
+ );
74
+ }
75
+ if (!Array.isArray(declaredSources) || !declaredSources.some((source) => source.kind === OCI_BUNDLE_SOURCE_KIND && source.name === pin.name)) {
76
+ throw new Error(`${label} is drawn from the third-party pin "${pin.name}", which the distribution does not declare as a sources[] entry of kind "${OCI_BUNDLE_SOURCE_KIND}"`);
77
+ }
78
+ return pin;
79
+ }
80
+
81
+ /**
82
+ * The image subjects of one third-party OCI row, from its pin and nothing else: one per service and runtime
83
+ * component the row ships (a worker that runs its sibling's image has none). Every pinned image must be one of them and
84
+ * every shipped image must be pinned, so the pin and the lock describe the same images.
85
+ *
86
+ * `contentHash` is the publisher's `sourceTreeHash` (publisher-attested); `sourceCommit` is the pin's.
87
+ */
88
+ export function thirdPartySubjectCandidates({ row, pin, enums, requiredPlatforms, label }) {
89
+ if (row.target === 'web') {
90
+ throw new Error(`${label} is a third-party OCI biome with target "web": a bundled web biome of a third party is not admitted (it has no pinned image)`);
91
+ }
92
+ const sharedImageWorkers = new Set(Object.keys(row.workerImages ?? {}));
93
+ const wanted = [
94
+ ...(row.services ?? []).filter((service) => !sharedImageWorkers.has(service)).map((id) => [enums.RuntimeImageSubjectKind.Service, id]),
95
+ ...(row.runtimeComponents ?? []).map((id) => [enums.RuntimeImageSubjectKind.RuntimeComponent, id]),
96
+ ];
97
+ const byKey = new Map(pin.images.map((image) => [`${image.subjectKind}\0${image.subjectId}`, image]));
98
+ const used = new Set();
99
+ const candidates = wanted.map(([kind, id]) => {
100
+ const image = byKey.get(`${kind}\0${id}`);
101
+ if (image === undefined) {
102
+ throw new Error(`${label} ships ${kind} "${id}", which the third-party pin "${pin.name}" pins no image for`);
103
+ }
104
+ used.add(`${kind}\0${id}`);
105
+ return {
106
+ kind,
107
+ id,
108
+ imageRef: image.imageRef,
109
+ repo: row.repo,
110
+ path: row.path,
111
+ sourceCommit: pin.sourceCommit,
112
+ sourceManifestHash: row.sourceManifestHash,
113
+ requiredPlatforms,
114
+ owners: [{ kind: enums.RuntimeImageOwnerKind.Biome, biomeId: row.id }],
115
+ contentHash: image.sourceTreeHash,
116
+ };
117
+ });
118
+ const unused = [...byKey.keys()].filter((key) => !used.has(key));
119
+ if (unused.length > 0) {
120
+ throw new Error(
121
+ `the third-party pin "${pin.name}" pins image(s) ${unused.map((key) => key.replace('\0', ':')).join(', ')} that ${label} does not ship`,
122
+ );
123
+ }
124
+ return candidates;
125
+ }
126
+
127
+ /** A third-party subject's content hash, as every CLI output prints it. */
128
+ export const attestedContentHash = (subject) => `contentHash (${PUBLISHER_ATTESTED_LABEL}) ${subject.contentHash}`;
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // `xema-edition arming`: is the data root's declared evidence trust policy ARMED
3
+ // on one channel?
4
+ //
5
+ // usage: xema-edition arming --channel <release|develop> --data-root <dir> --kernel <dir>
6
+ // stdout: {"channel":"<channel>","state":"armed|unarmed|partial","unmet":["…"]}
7
+ // exit: 0 only when the state is armed; 1 for any other state (each unmet
8
+ // entry on stderr) or a policy that does not hold to its pin; 2 for a
9
+ // usage error. A data root that declares no policy is `unarmed`.
10
+ // It reads the policy the way `xema-edition check` does and never writes.
11
+
12
+ import { EditionChannel, parseChannelArgs } from './edition-channel.mjs';
13
+ import {
14
+ WorkspaceRoot,
15
+ configureEditionWorkspaceFromArgv,
16
+ isEntryModule,
17
+ } from './edition-workspace.mjs';
18
+ import { assessDeclaredTrustPolicyArming } from './evidence-trust-policy.mjs';
19
+ import { TrustPolicyArmingState } from './trust-policy-arming.mjs';
20
+
21
+ export const ARMING_USAGE = `usage: xema-edition arming --channel <${Object.values(EditionChannel).join('|')}> --data-root <dir> --kernel <dir>`;
22
+
23
+ /** The process exit codes of the verb. */
24
+ export const ArmingExit = Object.freeze({ Armed: 0, NotArmed: 1, Usage: 2 });
25
+
26
+ /**
27
+ * Judge the configured data root's policy on `channel`, writing the one JSON
28
+ * line to `stdout` and each unmet entry to `stderr`. Returns the exit code.
29
+ */
30
+ export async function runArming({ channel, locateVerifier, stdout = process.stdout, stderr = process.stderr }) {
31
+ let verdict;
32
+ try {
33
+ verdict = await assessDeclaredTrustPolicyArming({ channel, locateVerifier });
34
+ } catch (error) {
35
+ stderr.write(`arming: ${error.message}\n`);
36
+ return ArmingExit.NotArmed;
37
+ }
38
+ const { state, unmet } = verdict;
39
+ stdout.write(`${JSON.stringify({ channel, state, unmet })}\n`);
40
+ if (state === TrustPolicyArmingState.ARMED) {return ArmingExit.Armed;}
41
+ for (const entry of unmet) {stderr.write(`${entry}\n`);}
42
+ return ArmingExit.NotArmed;
43
+ }
44
+
45
+ async function main() {
46
+ let channel;
47
+ try {
48
+ const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [WorkspaceRoot.Data, WorkspaceRoot.Kernel]);
49
+ ({ channel } = parseChannelArgs(rest));
50
+ } catch (error) {
51
+ process.stderr.write(`arming: ${error.message}\n${ARMING_USAGE}\n`);
52
+ process.exitCode = ArmingExit.Usage;
53
+ return;
54
+ }
55
+ process.exitCode = await runArming({ channel });
56
+ }
57
+
58
+ if (isEntryModule(import.meta.url)) {
59
+ await main();
60
+ }
@@ -0,0 +1,144 @@
1
+ // Arming assertion for the offline Sigstore trust policy.
2
+ //
3
+ // The policy a data root declares (`evidenceTrustPolicy`) is schema-valid while
4
+ // authorizing nobody: `policies`, `trustedCosignBinaryDigests` and
5
+ // `trustedGitBinaryDigests` all default to `[]` and the zod schema has no
6
+ // `.min(1)` on any of them. So the file parses green while the delivery plane
7
+ // remains deliberately disabled. That is a valid bootstrap state only while all
8
+ // three authority collections are empty. A partially populated policy is
9
+ // neither disabled nor usable and must fail `xema-edition check`; delivery
10
+ // itself always requires the fully armed state.
11
+ //
12
+ // It is deliberately not a lint of taste. Each requirement below names a place
13
+ // where an empty list makes verification impossible, not merely weaker:
14
+ //
15
+ // policies[] -> `image-evidence.mjs` throws "offline trust
16
+ // policy authorizes no signer for <predicate>
17
+ // on channel <channel>" before any cosign call.
18
+ // trustedCosignBinaryDigests -> `trusted-executable.mjs` compares the cosign
19
+ // binary digest with `[].includes(...)`, which
20
+ // is false for every possible binary.
21
+ // trustedGitBinaryDigests -> same, for the git binary that pins the
22
+ // graded checkouts.
23
+ //
24
+ // THE REQUIRED RELEASE ROWS ARE ONE RULE: the edition-catalogue (E4) predicate iff the edition consumes a catalogue
25
+ // (`consumesCatalogue`: a customer edition does, a vendor edition does not), plus the image producer, distribution
26
+ // completeness and delivery entitlement predicates. The same function judges the data root before the catalogue's
27
+ // kernel is adopted (inside the pinned verifier bundle, with its own embedded kernel) and after it (descriptorEvidenceTrust).
28
+ //
29
+ // The three predicate types are checked separately because a policy can be
30
+ // non-empty and still be unarmed for a stage: the verifier resolves candidates
31
+ // per predicate type, so authorizing only the producer identity leaves the
32
+ // completeness and entitlement stages throwing the same "authorizes no signer"
33
+ // at delivery time - armed-looking, and inert exactly where it matters.
34
+ //
35
+ // ARMING IS JUDGED FOR ONE CHANNEL: the channel of the edition being checked,
36
+ // sealed or verified. A release verification is only ever satisfied by a
37
+ // release signer and a develop one by a develop signer, so the signers of one
38
+ // channel neither arm nor disarm the other: a policy that authorizes the three
39
+ // stages on the release channel and pins both binaries is ARMED for a release
40
+ // edition whatever it says about develop.
41
+ //
42
+ // ONLY A STAGE ROW ARMS A CHANNEL: a row counts toward a channel's arming when it
43
+ // authorizes one of the stage predicates judged here. A bundle-signer row (SLSA
44
+ // provenance of a third-party bundle, e.g. the develop row of a git-sourced
45
+ // biome) authorizes bundle verification and arms no stage, so a channel whose
46
+ // only rows are bundle signers is UNARMED, exactly like a channel with no row.
47
+ // One stage row on the channel still makes it PARTIAL until every stage is met.
48
+
49
+ export const TrustPolicyArmingState = Object.freeze({
50
+ ARMED: 'armed',
51
+ UNARMED: 'unarmed',
52
+ PARTIAL: 'partial',
53
+ });
54
+
55
+ /** Arming has no channel-less answer: `channel` is one of the kernel's evidence channels. */
56
+ export function assertArmingChannel(schemas, channel) {
57
+ if (!schemas.EVIDENCE_CHANNELS.includes(channel)) {
58
+ throw new Error(
59
+ `trust-policy arming is judged for one channel; got ${JSON.stringify(channel)}, expected ${schemas.EVIDENCE_CHANNELS.join(' | ')}`,
60
+ );
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Assess whether a parsed trust policy can authorize anything on `channel`.
66
+ *
67
+ * Returns `{ state, armed, unmet }`. `unmet` is empty exactly when `armed` is
68
+ * true. `unarmed` means the channel has no signer at all in a policy that is
69
+ * otherwise coherent: the exact, explicit bootstrap state where every authority
70
+ * collection is empty, or a policy whose signers all belong to another channel.
71
+ * Anything else that is not armed is `partial`. Each unmet entry names the
72
+ * field and what makes verification impossible, so the failure is actionable
73
+ * without reading the verifier.
74
+ */
75
+ export function assessTrustPolicyArming(policy, schemas, channel, { consumesCatalogue = false } = {}) {
76
+ assertArmingChannel(schemas, channel);
77
+ const unmet = [];
78
+ const stages = [
79
+ ['image-producer', schemas.IMAGE_PRODUCER_PREDICATE_TYPE],
80
+ ['distribution-completeness', schemas.DISTRIBUTION_IMAGES_PREDICATE_TYPE],
81
+ ['delivery-entitlement', schemas.IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE],
82
+ ];
83
+ if (consumesCatalogue) {
84
+ // A kernel that states no E4 predicate cannot arm a catalogue consumer: an unmet entry, never a skip.
85
+ if (typeof schemas.EDITION_CATALOGUE_PREDICATE_TYPE !== 'string') {
86
+ unmet.push('this kernel states no edition-catalogue predicate (EDITION_CATALOGUE_PREDICATE_TYPE), so a catalogue consumer cannot be armed against it.');
87
+ } else {
88
+ stages.push(['edition-catalogue', schemas.EDITION_CATALOGUE_PREDICATE_TYPE]);
89
+ }
90
+ }
91
+ const stagePredicateTypes = stages.map(([, predicateType]) => predicateType);
92
+ const stageRows = policy.policies.filter(
93
+ (candidate) => candidate.channel === channel && candidate.predicateTypes.some((predicateType) => stagePredicateTypes.includes(predicateType)),
94
+ );
95
+ const authorized = stages.filter(([, predicateType]) => stageRows.some((candidate) => candidate.predicateTypes.includes(predicateType)));
96
+
97
+ if (policy.policies.length === 0) {
98
+ unmet.push(
99
+ 'policies[] is empty — no signer identity is authorized, so ' +
100
+ 'verifySigstoreEvidenceDescriptor throws "offline trust policy ' +
101
+ 'authorizes no signer" for every predicate before reading any bundle.',
102
+ );
103
+ } else {
104
+ for (const [stage, predicateType] of stages.filter((candidate) => !authorized.includes(candidate))) {
105
+ unmet.push(
106
+ `no policy authorizes the ${stage} predicate ${predicateType} on channel "${channel}" — ` +
107
+ `that stage of a ${channel} delivery would still throw "authorizes no signer" ` +
108
+ 'while the policy reads as armed.',
109
+ );
110
+ }
111
+ }
112
+
113
+ if (policy.trustedCosignBinaryDigests.length === 0) {
114
+ unmet.push(
115
+ 'trustedCosignBinaryDigests[] is empty — prepareDigestPinnedExecutable ' +
116
+ 'tests membership with [].includes(digest), which is false for every ' +
117
+ 'cosign binary, so no evidence can be verified on any host.',
118
+ );
119
+ }
120
+
121
+ if (policy.trustedGitBinaryDigests.length === 0) {
122
+ unmet.push(
123
+ 'trustedGitBinaryDigests[] is empty — the same membership test rejects ' +
124
+ 'every git binary, so no verified release checkout can be established.',
125
+ );
126
+ }
127
+
128
+ const armed = unmet.length === 0;
129
+ const bootstrap =
130
+ policy.policies.length === 0 &&
131
+ policy.trustedCosignBinaryDigests.length === 0 &&
132
+ policy.trustedGitBinaryDigests.length === 0;
133
+ // The channel has no stage row while the policy has rows (another channel's,
134
+ // or bundle signers): those rows and the binary pins are not half of this
135
+ // channel's arming.
136
+ const noStageRow = stageRows.length === 0 && policy.policies.length > 0;
137
+ const state = armed
138
+ ? TrustPolicyArmingState.ARMED
139
+ : bootstrap || noStageRow
140
+ ? TrustPolicyArmingState.UNARMED
141
+ : TrustPolicyArmingState.PARTIAL;
142
+
143
+ return { state, armed, unmet };
144
+ }
@@ -0,0 +1,85 @@
1
+ // Who may name which image repository in the evidence trust policy (XEMA-1611 design section 3(e)).
2
+ //
3
+ // Checked when the declared policy is LOADED, so every consumer of the policy sees it:
4
+ // (a) an `imageRepositories` entry listed by rows whose `githubWorkflow.repository` OWNERS differ is
5
+ // refused: one repository, one publisher;
6
+ // (b) a row whose owner is not a first-party owner and that names a first-party namespace
7
+ // (`<registry>/<first-party owner>/<any>`) is refused.
8
+ // First-party owners are the owners of the runtime-image-source index `repositoryPins` URLs, plus the kernel scope owner.
9
+
10
+ import { KERNEL_PACKAGE_NAME } from './kernel-package.mjs';
11
+
12
+ /**
13
+ * The vendor's publishing owner: the npm scope of the kernel package (`@<scope>/contracts`), read from the package name
14
+ * the CLI already pins, not written here. It is a first-party owner on purpose, beside the owners of the index's
15
+ * `repositoryPins`: the vendor publishes its kernel-scoped images under that owner, which no repository pin names, so
16
+ * without it a vendor row for them would be refused by rule (b) as a third party.
17
+ */
18
+ export const KERNEL_SCOPE_OWNER = KERNEL_PACKAGE_NAME.replace(/^@/u, '').split('/')[0];
19
+
20
+ /** The lowercase owners of the `repositoryPins[].repositoryUrl` entries of a parsed runtime image source index, plus the kernel scope owner. */
21
+ export function firstPartyOwnersFromIndex(indexDocument) {
22
+ const pins = indexDocument?.repositoryPins;
23
+ if (!Array.isArray(pins)) {
24
+ throw new Error('runtime image source index has no repositoryPins array to derive the first-party owners from');
25
+ }
26
+ const owners = new Set([KERNEL_SCOPE_OWNER]);
27
+ for (const pin of pins) {
28
+ let url;
29
+ try {
30
+ url = new URL(pin?.repositoryUrl);
31
+ } catch {
32
+ throw new Error(`runtime image source index repositoryPins entry ${JSON.stringify(pin?.repo)} has no parsable repositoryUrl`);
33
+ }
34
+ const owner = url.pathname.split('/').filter(Boolean)[0];
35
+ if (owner === undefined) {
36
+ throw new Error(`runtime image source index repositoryUrl ${url.href} names no owner`);
37
+ }
38
+ owners.add(owner.toLowerCase());
39
+ }
40
+ return owners;
41
+ }
42
+
43
+ /**
44
+ * Whether an image repository sits in a first-party namespace: `<registry>/<first-party owner>/<any>`, an owner being
45
+ * one of `firstPartyOwners` (the data root's own, from `firstPartyOwnersFromIndex`). The registry is whatever the first
46
+ * path segment is; no registry or organisation is named here.
47
+ */
48
+ export function isFirstPartyImageNamespace(imageRepository, firstPartyOwners) {
49
+ const segments = imageRepository.toLowerCase().split('/');
50
+ return segments.slice(1, -1).some((segment) => firstPartyOwners.has(segment));
51
+ }
52
+
53
+ const rowOwner = (row) => row.githubWorkflow?.repository?.split('/')[0]?.toLowerCase();
54
+
55
+ /**
56
+ * Refuse a policy that breaks (a) or (b). `firstPartyOwners` is `null` when the data root has no runtime image source
57
+ * index (a customer data root): (b) cannot be judged there and is not applied; (a) always is.
58
+ *
59
+ * @param {{policy: {policies: object[]}, firstPartyOwners: Set<string>|null}} options
60
+ */
61
+ export function assertTrustPolicyImageOwnership({ policy, firstPartyOwners }) {
62
+ const ownersByRepository = new Map();
63
+ for (const row of policy.policies) {
64
+ const owner = rowOwner(row);
65
+ if (owner === undefined || !Array.isArray(row.imageRepositories)) {
66
+ continue;
67
+ }
68
+ for (const imageRepository of row.imageRepositories) {
69
+ const seen = ownersByRepository.get(imageRepository);
70
+ if (seen !== undefined && seen.owner !== owner) {
71
+ throw new Error(
72
+ `evidence trust policy: image repository ${imageRepository} is listed by rows of two publishers ` +
73
+ `(${seen.policyId} owned by ${seen.owner}, ${row.policyId} owned by ${owner}); one image repository has one publisher`,
74
+ );
75
+ }
76
+ ownersByRepository.set(imageRepository, { owner, policyId: row.policyId });
77
+ if (firstPartyOwners !== null && !firstPartyOwners.has(owner) && isFirstPartyImageNamespace(imageRepository, firstPartyOwners)) {
78
+ throw new Error(
79
+ `evidence trust policy: row ${row.policyId} (owner ${owner}, not a first-party owner) names image repository ` +
80
+ `${imageRepository} in a first-party namespace (<registry>/<first-party owner>/<any>)`,
81
+ );
82
+ }
83
+ }
84
+ }
85
+ }