@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,1064 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // THE DISTRIBUTION-LEVEL EVIDENCE SET — ONE OWNER FOR ITS SHAPE
3
+ //
4
+ // `produce-image-source-evidence.mjs` signs ONE statement per image, in the
5
+ // repository that built it. This module owns everything that happens ABOVE
6
+ // that: gathering those per-image bundles for one edition, stating that the
7
+ // set is COMPLETE, entitling a customer to a delivery of it, and assembling
8
+ // the `evidence-set.json` the offline verifier reads.
9
+ //
10
+ // It exists as its own module because three entrypoints need exactly the
11
+ // same shapes and would otherwise each grow their own copy:
12
+ //
13
+ // * produce-distribution-completeness-evidence.mjs (signs completeness)
14
+ // * produce-image-delivery-entitlement-evidence.mjs (signs entitlement)
15
+ // * distribution-evidence-producer-contract-tests.mjs (proves both agree
16
+ // with the VERIFIER's own binding validators)
17
+ //
18
+ // Every shape here is parsed through the kernel schema before it is returned,
19
+ // so a statement this module builds and a statement the verifier accepts are
20
+ // the same object by construction, not by review.
21
+ //
22
+ // WHAT IS DELIBERATELY *NOT* HERE
23
+ //
24
+ // Signature verification. Staging reads committed bundles and proves they are
25
+ // internally consistent (descriptor digests, DSSE payload equality, kernel
26
+ // schema, exact source coordinate) — it does NOT verify Sigstore signatures.
27
+ // That is `image-evidence.mjs verifyDistributionImageEvidenceSet`, offline,
28
+ // against the armed trust policy, at delivery. Re-implementing it here would
29
+ // be a second trust decision in a place with no trust anchor.
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+
32
+ import { createHash } from 'node:crypto';
33
+ import {
34
+ mkdirSync,
35
+ readFileSync,
36
+ readdirSync,
37
+ writeFileSync,
38
+ } from 'node:fs';
39
+ import { dirname, isAbsolute, join } from 'node:path';
40
+
41
+ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
42
+ import { editionWorkspaceConfig } from './edition-config.mjs';
43
+ import { COMMIT_PATTERN, SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
44
+ import {
45
+ loadPinnedDeclaredTrustPolicy,
46
+ } from './evidence-trust-pin.mjs';
47
+ import {
48
+ EVIDENCE_TREE_ROOT,
49
+ canonicalJson,
50
+ canonicalJsonBytes,
51
+ compareCompletenessImages,
52
+ decodeExactUtf8,
53
+ evidenceDirectoryForDigest,
54
+ loadValidatedLocks,
55
+ normalizedImageLockDigest,
56
+ sha256Digest,
57
+ sourceIdentity,
58
+ } from './image-evidence.mjs';
59
+ import { completenessImageKey } from './image-subject-identity.mjs';
60
+ import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
61
+ import { compareStrings } from './lib.mjs';
62
+ import { readBoundedNoFollowFile } from './no-follow-file.mjs';
63
+ import {
64
+ createRegistryClientResolver,
65
+ describeInvalidReferrer,
66
+ resolveImageSourceEvidenceReferrer,
67
+ writeInvalidReferrerReport,
68
+ } from './oci-image-source-evidence.mjs';
69
+ import { BUNDLE_MEDIA_TYPE, canonicalSignedBundleBytes, cosignVerifyAttestationArgs, validateProducedBundle } from './produce-image-source-evidence.mjs';
70
+ import { splitImageRef } from './registry-credentials.mjs';
71
+ import { imageSubjectKey, thirdPartyImagePins } from './third-party-image-pins.mjs';
72
+ import { thirdPartyProducerRecord } from './third-party-producer-evidence.mjs';
73
+
74
+ /**
75
+ * The ONE Sigstore bundle media type this plane stores — re-exported, never
76
+ * re-declared. The kernel's `SigstoreEvidenceDescriptorSchema` accepts this
77
+ * literal and nothing else, and `produce-image-source-evidence.mjs` owns it.
78
+ */
79
+ export { BUNDLE_MEDIA_TYPE };
80
+
81
+ /** Same bound `check-committed-image-evidence.mjs` reads committed files at. */
82
+ const MAX_EVIDENCE_FILE_BYTES = 4 * 1024 * 1024;
83
+
84
+ /** `evidence/sha256-<64 hex>` — the shape `evidenceDirectoryForDigest` emits. */
85
+ const EVIDENCE_DIRECTORY_PATTERN = /^sha256-[a-f0-9]{64}$/;
86
+
87
+
88
+ function assertAbsolutePath(value, label) {
89
+ if (typeof value !== 'string' || !isAbsolute(value)) {
90
+ throw new Error(`${label} must be an absolute path`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /**
96
+ * The issuance window a statement may carry.
97
+ *
98
+ * `image-evidence.mjs assertTimeWindow` is the VERIFIER's copy of this rule and
99
+ * additionally compares against `now`. A producer cannot use it: it signs at
100
+ * issuance, so "not yet valid" and "expired" are not decidable statements about
101
+ * its own output — but the MAXIMUM TTL is, and refusing an over-long TTL here
102
+ * is what stops a statement that can never verify from being signed at all.
103
+ */
104
+ function assertIssuanceWindow({ issuedAt, expiresAt, maxTtlSeconds, label }) {
105
+ const issued = Date.parse(issuedAt);
106
+ const expires = Date.parse(expiresAt);
107
+ if (!Number.isFinite(issued) || !Number.isFinite(expires)) {
108
+ throw new Error(`${label} has an invalid issuedAt/expiresAt`);
109
+ }
110
+ if (!Number.isSafeInteger(maxTtlSeconds) || maxTtlSeconds <= 0) {
111
+ throw new Error(`${label} requires a positive maximum TTL from the policy`);
112
+ }
113
+ if (expires <= issued) {
114
+ throw new Error(`${label} expiresAt must be after issuedAt`);
115
+ }
116
+ if (expires - issued > maxTtlSeconds * 1000) {
117
+ throw new Error(
118
+ `${label} TTL ${Math.floor((expires - issued) / 1000)}s exceeds the ` +
119
+ `policy maximum of ${maxTtlSeconds}s`,
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The canonical order of an evidence subject list — the VERIFIER's own
126
+ * comparator, re-exported. `validateCompletenessStatementBinding` refuses a
127
+ * statement whose `images[]` is not in this order, so a second copy here would
128
+ * be a comparator a signature is bound to, drifting from the one that judges it.
129
+ */
130
+ export { compareCompletenessImages as compareEvidenceSubjects };
131
+
132
+ /**
133
+ * Deterministic per-subject artifact stem inside a staged evidence root.
134
+ * Keyed by the same `kind\0id` identity `image-evidence.mjs` uses everywhere
135
+ * else, so two subjects can never collide and one subject can never move.
136
+ */
137
+ export function subjectStem(subject) {
138
+ return createHash('sha256')
139
+ .update(sourceIdentity(subject))
140
+ .digest('hex');
141
+ }
142
+
143
+ /**
144
+ * Nonces are DERIVED from run identity, never random.
145
+ *
146
+ * Same construction as `evidenceNonce` in `produce-image-source-evidence.mjs`,
147
+ * with its own domain-separation prefix so a completeness nonce can never
148
+ * collide with a producer or entitlement nonce — the verifier keeps ONE
149
+ * consumed-nonce set across all three and rejects a reuse
150
+ * (`validateCompletenessStatementBinding` refuses a statement that replays a
151
+ * producer nonce).
152
+ *
153
+ * Derivation, not randomness, is what makes a re-run of the SAME release
154
+ * byte-identical: a random nonce would make every retry a distinct statement
155
+ * and the replay state could never tell a retry from a rollback attempt.
156
+ */
157
+ function derivedNonce(domain, parts) {
158
+ const hash = createHash('sha256').update(`${domain}\0`);
159
+ for (const part of parts) {
160
+ hash.update(String(part)).update('\0');
161
+ }
162
+ return hash.digest('base64url');
163
+ }
164
+
165
+ // ─────────────────────────────────────────────────────────────────────────────
166
+ // Reading the committed producer evidence of every producing repository
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+
169
+ function readCommittedArtifact(evidenceDirectory, relativePath, label) {
170
+ return readBoundedNoFollowFile(
171
+ evidenceDirectory,
172
+ relativePath,
173
+ label,
174
+ MAX_EVIDENCE_FILE_BYTES,
175
+ ).bytes;
176
+ }
177
+
178
+ /**
179
+ * Index every committed producer bundle reachable from the given producing
180
+ * repository checkouts, keyed by the EXACT source coordinate its statement
181
+ * attests (`canonicalJson(predicate.source)`).
182
+ *
183
+ * `onDefect` is REQUIRED and has no default: the two callers make opposite —
184
+ * and both correct — decisions about a malformed committed bundle. Staging must
185
+ * abort (it is about to build a release around this evidence); the refresh
186
+ * planner must classify and continue (its entire job is reporting what is
187
+ * broken). A default here would silently give one of them the other's posture.
188
+ *
189
+ * @returns {Map<string, Array<object>>}
190
+ */
191
+ export function indexCommittedProducerEvidence({
192
+ producerRepositoryRoots,
193
+ schemas,
194
+ onDefect,
195
+ }) {
196
+ if (
197
+ !Array.isArray(producerRepositoryRoots) ||
198
+ producerRepositoryRoots.length === 0
199
+ ) {
200
+ throw new Error('at least one producing repository root is required');
201
+ }
202
+ if (typeof onDefect !== 'function') {
203
+ throw new Error(
204
+ 'a defect handler is required — staging and planning must not share a default posture',
205
+ );
206
+ }
207
+ if (!schemas?.parseImageProducerStatement) {
208
+ throw new Error('trusted kernel distribution schemas are required');
209
+ }
210
+ const seenRoots = new Set();
211
+ const index = new Map();
212
+ for (const root of producerRepositoryRoots) {
213
+ assertAbsolutePath(root, 'producing repository root');
214
+ if (seenRoots.has(root)) {
215
+ throw new Error(`producing repository root ${root} was given twice`);
216
+ }
217
+ seenRoots.add(root);
218
+ const evidenceRoot = join(root, EVIDENCE_TREE_ROOT);
219
+ let entries;
220
+ try {
221
+ entries = readdirSync(evidenceRoot, { withFileTypes: true });
222
+ } catch {
223
+ // A producing repository that has never produced evidence is not a
224
+ // defect — it is exactly the ABSENT verdict the planner must report and
225
+ // the "no matching bundle" failure staging must raise, per subject.
226
+ continue;
227
+ }
228
+ for (const entry of [...entries].sort((left, right) =>
229
+ compareStrings(left.name, right.name),
230
+ )) {
231
+ const directory = join(evidenceRoot, entry.name);
232
+ if (entry.isSymbolicLink()) {
233
+ onDefect({
234
+ root,
235
+ directory,
236
+ subjectKey: null,
237
+ reason: `${EVIDENCE_TREE_ROOT}/${entry.name} is a symlink`,
238
+ });
239
+ continue;
240
+ }
241
+ if (!entry.isDirectory()) {continue;}
242
+ if (!EVIDENCE_DIRECTORY_PATTERN.test(entry.name)) {
243
+ onDefect({
244
+ root,
245
+ directory,
246
+ subjectKey: null,
247
+ reason: `${EVIDENCE_TREE_ROOT}/${entry.name} is not a sha256-<digest> evidence directory`,
248
+ });
249
+ continue;
250
+ }
251
+ indexOneEvidenceDirectory({
252
+ root,
253
+ directory,
254
+ directoryName: entry.name,
255
+ schemas,
256
+ onDefect,
257
+ index,
258
+ });
259
+ }
260
+ }
261
+ return index;
262
+ }
263
+
264
+ function indexOneEvidenceDirectory({
265
+ root,
266
+ directory,
267
+ directoryName,
268
+ schemas,
269
+ onDefect,
270
+ index,
271
+ }) {
272
+ let indexDocument;
273
+ try {
274
+ indexDocument = JSON.parse(
275
+ decodeExactUtf8(
276
+ readCommittedArtifact(directory, 'producer-evidence.json', 'producer index'),
277
+ 'producer index',
278
+ ),
279
+ );
280
+ } catch (error) {
281
+ onDefect({
282
+ root,
283
+ directory,
284
+ subjectKey: null,
285
+ reason: `producer-evidence.json is missing or unreadable: ${error.message}`,
286
+ });
287
+ return;
288
+ }
289
+ if (
290
+ indexDocument?.schemaVersion !== 1 ||
291
+ !Array.isArray(indexDocument.producerEvidence) ||
292
+ indexDocument.producerEvidence.length === 0
293
+ ) {
294
+ onDefect({
295
+ root,
296
+ directory,
297
+ subjectKey: null,
298
+ reason: 'producer-evidence.json is not a non-empty schemaVersion 1 index',
299
+ });
300
+ return;
301
+ }
302
+ for (const entry of indexDocument.producerEvidence) {
303
+ if (
304
+ entry === null ||
305
+ typeof entry !== 'object' ||
306
+ typeof entry.subjectKind !== 'string' ||
307
+ typeof entry.subjectId !== 'string' ||
308
+ entry.evidence === null ||
309
+ typeof entry.evidence !== 'object'
310
+ ) {
311
+ onDefect({
312
+ root,
313
+ directory,
314
+ subjectKey: null,
315
+ reason: 'producer-evidence.json carries a malformed entry',
316
+ });
317
+ continue;
318
+ }
319
+ // Only named in a defect: the record is indexed by the statement's own source coordinate, which already
320
+ // tells the generations of a composed artifact apart (its sourceTreeHash).
321
+ const subjectKey = `${entry.subjectKind}:${entry.subjectId}`;
322
+ const descriptor = entry.evidence;
323
+ let statementBytes;
324
+ let bundleBytes;
325
+ let statement;
326
+ try {
327
+ statementBytes = readCommittedArtifact(
328
+ directory,
329
+ descriptor.statementPath,
330
+ `${entry.subjectKind}:${entry.subjectId} statement`,
331
+ );
332
+ bundleBytes = readCommittedArtifact(
333
+ directory,
334
+ descriptor.bundlePath,
335
+ `${entry.subjectKind}:${entry.subjectId} bundle`,
336
+ );
337
+ if (
338
+ sha256Digest(statementBytes) !== descriptor.statementDigest ||
339
+ sha256Digest(bundleBytes) !== descriptor.bundleDigest ||
340
+ descriptor.bundleMediaType !== BUNDLE_MEDIA_TYPE
341
+ ) {
342
+ throw new Error(
343
+ 'committed artifacts do not match the digests and media type producer-evidence.json records',
344
+ );
345
+ }
346
+ // Proves the bundle carries exactly ONE DSSE in-toto signature whose
347
+ // payload is byte-identical to the statement. Reused from the producer so
348
+ // the staged bytes are validated by the very code that wrote them.
349
+ validateProducedBundle(statementBytes, bundleBytes);
350
+ statement = schemas.parseImageProducerStatement(
351
+ JSON.parse(decodeExactUtf8(statementBytes, 'committed statement')),
352
+ );
353
+ if (
354
+ evidenceDirectoryForDigest(statement.predicate.artifact.manifestDigest) !==
355
+ `${EVIDENCE_TREE_ROOT}/${directoryName}`
356
+ ) {
357
+ throw new Error(
358
+ `statement attests ${statement.predicate.artifact.manifestDigest}, which is not the digest this directory is named after`,
359
+ );
360
+ }
361
+ if (
362
+ statement.predicate.source.kind !== entry.subjectKind ||
363
+ statement.predicate.source.id !== entry.subjectId
364
+ ) {
365
+ throw new Error(
366
+ `statement attests ${statement.predicate.source.kind}:${statement.predicate.source.id}, which producer-evidence.json indexes as ${entry.subjectKind}:${entry.subjectId}`,
367
+ );
368
+ }
369
+ } catch (error) {
370
+ onDefect({ root, directory, subjectKey, reason: error.message });
371
+ continue;
372
+ }
373
+ const coordinate = canonicalJson(statement.predicate.source);
374
+ const record = {
375
+ root,
376
+ directory,
377
+ subjectKind: statement.predicate.source.kind,
378
+ subjectId: statement.predicate.source.id,
379
+ statement,
380
+ statementBytes,
381
+ bundleBytes,
382
+ descriptor,
383
+ };
384
+ const incumbent = index.get(coordinate);
385
+ if (incumbent === undefined) {
386
+ index.set(coordinate, [record]);
387
+ } else {
388
+ incumbent.push(record);
389
+ }
390
+ }
391
+ }
392
+
393
+ /**
394
+ * The exact source coordinate a producer statement must attest for one locked
395
+ * subject: the lock's own fields joined with the repository pin the runtime
396
+ * index committed. Value-identical to `expectedSource` in
397
+ * `image-evidence.mjs validateProducerStatementBinding` — that function is the
398
+ * verifier's authority and the contract tests prove agreement by round-tripping
399
+ * a staged statement through it.
400
+ */
401
+ export function expectedProducerSource({
402
+ subject,
403
+ repositoryPin,
404
+ sourceHashAlgorithm,
405
+ }) {
406
+ if (
407
+ repositoryPin?.repo !== subject.repo ||
408
+ repositoryPin.sourceCommit !== subject.sourceCommit
409
+ ) {
410
+ throw new Error(
411
+ `runtime subject "${subject.kind}:${subject.id}" does not match the committed repository pin for ${subject.repo}`,
412
+ );
413
+ }
414
+ return {
415
+ kind: subject.kind,
416
+ id: subject.id,
417
+ repositoryUrl: repositoryPin.repositoryUrl,
418
+ repo: subject.repo,
419
+ path: subject.path,
420
+ sourceCommit: subject.sourceCommit,
421
+ sourceManifestHash: subject.sourceManifestHash,
422
+ sourceHashAlgorithm,
423
+ sourceTreeHash: subject.contentHash,
424
+ };
425
+ }
426
+
427
+ /**
428
+ * Resolve the committed producer bundle of every subject in one edition's
429
+ * `images.lock.json` and COPY the pairs into `outputRoot`.
430
+ *
431
+ * Copying (rather than referencing the producing checkouts in place) is what
432
+ * makes the evidence root a self-contained artifact: `evidence-set.json`
433
+ * descriptor paths are relative to it, and the offline verifier is handed that
434
+ * one directory with no access to any producing repository.
435
+ *
436
+ * @returns {Promise<{distributionLock, imageLock, producers: Array<object>,
437
+ * producerEvidence: Array<object>, images: Array<object>}>}
438
+ */
439
+ export async function stageProducerEvidence({ outputRoot, ...resolution }) {
440
+ assertAbsolutePath(outputRoot, 'staging output root');
441
+ const resolved = await resolveProducerEvidence(resolution);
442
+ for (const producer of resolved.producers) {
443
+ mkdirSync(join(outputRoot, 'producers', producer.stem), { recursive: true, mode: 0o700 });
444
+ for (const [path, bytes] of stagedProducerFiles(producer)) {
445
+ writeFileSync(join(outputRoot, path), bytes, { flag: 'wx', mode: 0o644 });
446
+ }
447
+ }
448
+ return resolved;
449
+ }
450
+
451
+ /** The staged path and the exact bytes of both files of one producer's evidence. */
452
+ export function stagedProducerFiles(producer) {
453
+ return [
454
+ [producer.evidence.statementPath, producer.statementBytes],
455
+ [producer.evidence.bundlePath, producer.bundleBytes],
456
+ ];
457
+ }
458
+
459
+ /**
460
+ * Resolve the published producer evidence of every subject in one edition's
461
+ * `images.lock.json`; writes nothing. The accept re-derives it with this and
462
+ * compares it with the staging, so what it seals is what the registry serves.
463
+ */
464
+ export async function resolveProducerEvidence({
465
+ distributionId,
466
+ dataRoot,
467
+ editionsRoot,
468
+ schemas,
469
+ expectedContractAuthority,
470
+ registryClientFactory,
471
+ // The reader of the edition's validated locks; a seam so the assembly is testable without a whole edition tree.
472
+ loadLocks = loadValidatedLocks,
473
+ }) {
474
+ assertCanonicalDistributionId(distributionId);
475
+ assertAbsolutePath(dataRoot, 'data root');
476
+ assertAbsolutePath(editionsRoot, 'editions root');
477
+ if (!expectedContractAuthority) {
478
+ throw new Error('the expected kernel contract authority is required');
479
+ }
480
+ const { distributionLock, imageLock, repositoryPins, runtimeSourceIndex, thirdPartySources } =
481
+ await loadLocks(distributionId, dataRoot, schemas, { editionsRoot });
482
+ assertKernelContractAuthorityEqual(
483
+ runtimeSourceIndex.contractAuthority,
484
+ expectedContractAuthority,
485
+ 'runtime-image-source-index.json',
486
+ );
487
+ const clientFor = createRegistryClientResolver({
488
+ dataRoot,
489
+ factory: registryClientFactory,
490
+ });
491
+
492
+ const thirdPartyImages = thirdPartyImagePins({
493
+ pins: { byRepository: (repository) => thirdPartySources.get(repository) },
494
+ subjects: imageLock.subjects,
495
+ });
496
+ const producers = [];
497
+ for (const subject of imageLock.subjects) {
498
+ const pinImage = thirdPartyImages.get(imageSubjectKey(subject));
499
+ if (pinImage !== undefined) {
500
+ // A third-party subject's contentHash is publisher-attested, not a tag: its evidence is the committed evidence its pin
501
+ // binds, read from the data root with no registry call.
502
+ producers.push(
503
+ thirdPartyProducerRecord({
504
+ subject,
505
+ pinImage,
506
+ dataRoot,
507
+ schemas,
508
+ expectedSource: expectedProducerSource({
509
+ subject,
510
+ repositoryPin: repositoryPins.get(subject.repo),
511
+ sourceHashAlgorithm: imageLock.sourceHashAlgorithm,
512
+ }),
513
+ stem: subjectStem(subject),
514
+ }),
515
+ );
516
+ continue;
517
+ }
518
+ const expectedSource = expectedProducerSource({
519
+ subject,
520
+ repositoryPin: repositoryPins.get(subject.repo),
521
+ sourceHashAlgorithm: imageLock.sourceHashAlgorithm,
522
+ });
523
+ const { authority, repository } = splitImageRef(subject.imageRef);
524
+ const client = clientFor(authority);
525
+ const image = await client.getManifestByTag(repository, subject.contentHash);
526
+ const invalidReferrers = [];
527
+ const ociEvidence = await resolveImageSourceEvidenceReferrer({
528
+ client,
529
+ imageRepository: subject.imageRef,
530
+ subjectDigest: image.digest,
531
+ schemas,
532
+ expectedContractAuthority,
533
+ authorityBound: true, // first-party subject: bound to the contract authority on every channel (authority-binding.mjs)
534
+ onInvalidReferrer: (skipped) => {
535
+ invalidReferrers.push(skipped);
536
+ writeInvalidReferrerReport(skipped);
537
+ },
538
+ });
539
+ if (ociEvidence === null && invalidReferrers.length > 0) {
540
+ throw new Error(
541
+ `no valid OCI image-source evidence referrer exists for "${subject.kind}:${subject.id}" ` +
542
+ `at ${subject.imageRef}@${image.digest}: ` +
543
+ invalidReferrers.map(describeInvalidReferrer).join('; '),
544
+ );
545
+ }
546
+ if (ociEvidence === null) {
547
+ throw new Error(
548
+ `no OCI image-source evidence referrer exists for "${subject.kind}:${subject.id}" ` +
549
+ `at ${subject.imageRef}@${image.digest}`,
550
+ );
551
+ }
552
+ const matches = ociEvidence.records
553
+ .filter(
554
+ (record) =>
555
+ canonicalJson(record.statement.predicate.source) ===
556
+ canonicalJson(expectedSource),
557
+ )
558
+ .map((record) => ({
559
+ ...record,
560
+ root: subject.imageRef,
561
+ directory: `oci://${subject.imageRef}@${ociEvidence.artifactDigest}`,
562
+ }));
563
+ if (matches.length === 0) {
564
+ throw new Error(
565
+ `no published producer evidence attests "${subject.kind}:${subject.id}" at ` +
566
+ `${subject.repo}@${subject.sourceCommit} (source tree ${subject.contentHash})`,
567
+ );
568
+ }
569
+ const [match] = matches;
570
+ const { artifact } = match.statement.predicate;
571
+ if (
572
+ artifact.imageRepository !== subject.imageRef ||
573
+ match.statement.subject[0].name !== subject.imageRef ||
574
+ `sha256:${match.statement.subject[0].digest.sha256}` !==
575
+ artifact.manifestDigest
576
+ ) {
577
+ throw new Error(
578
+ `committed producer evidence for "${subject.kind}:${subject.id}" attests image ` +
579
+ `"${artifact.imageRepository}", not the locked "${subject.imageRef}"`,
580
+ );
581
+ }
582
+ const stem = subjectStem(subject);
583
+ producers.push({
584
+ stem,
585
+ subjectKind: subject.kind,
586
+ subjectId: subject.id,
587
+ imageRepository: subject.imageRef,
588
+ manifestDigest: artifact.manifestDigest,
589
+ statement: match.statement,
590
+ sourceDirectory: match.directory,
591
+ statementBytes: match.statementBytes,
592
+ bundleBytes: match.bundleBytes,
593
+ evidence: {
594
+ statementPath: `producers/${stem}/statement.json`,
595
+ statementDigest: sha256Digest(match.statementBytes),
596
+ bundlePath: `producers/${stem}/bundle.sigstore.json`,
597
+ bundleDigest: sha256Digest(match.bundleBytes),
598
+ bundleMediaType: BUNDLE_MEDIA_TYPE,
599
+ },
600
+ });
601
+ }
602
+ producers.sort(compareCompletenessImages);
603
+
604
+ return {
605
+ distributionLock,
606
+ imageLock,
607
+ producers,
608
+ producerEvidence: producers.map(({ subjectKind, subjectId, evidence }) => ({
609
+ subjectKind,
610
+ subjectId,
611
+ evidence,
612
+ })),
613
+ images: producers.map((producer) => ({
614
+ subjectKind: producer.subjectKind,
615
+ subjectId: producer.subjectId,
616
+ imageRepository: producer.imageRepository,
617
+ manifestDigest: producer.manifestDigest,
618
+ producerStatementDigest: producer.evidence.statementDigest,
619
+ producerBundleDigest: producer.evidence.bundleDigest,
620
+ })),
621
+ };
622
+ }
623
+
624
+ // ─────────────────────────────────────────────────────────────────────────────
625
+ // Statement shapes
626
+ // ─────────────────────────────────────────────────────────────────────────────
627
+
628
+ /**
629
+ * The evidence channel a signer signs for: the one whose kernel-assigned ref (`EVIDENCE_CHANNEL_REFS`) is the
630
+ * signer's workflow ref. The kernel's policy schema ties each policy row to exactly that ref, so the signer's ref
631
+ * IS the channel the verifying row will state. A ref no channel owns is refused.
632
+ */
633
+ export function evidenceChannelOfSigner(signer, schemas) {
634
+ const ref = signer?.githubWorkflow?.ref;
635
+ const channel = schemas.EVIDENCE_CHANNELS.find((candidate) => schemas.EVIDENCE_CHANNEL_REFS[candidate] === ref);
636
+ if (channel === undefined) {
637
+ throw new Error(
638
+ `the signer workflow ref "${String(ref)}" is the ref of no evidence channel (${schemas.EVIDENCE_CHANNELS.map((candidate) => `${candidate}: ${schemas.EVIDENCE_CHANNEL_REFS[candidate]}`).join(', ')})`,
639
+ );
640
+ }
641
+ return channel;
642
+ }
643
+
644
+ /**
645
+ * The distribution-completeness statement: "these EXACT images, produced by
646
+ * these EXACT bundles, are the complete image set of this edition at this
647
+ * release commit."
648
+ */
649
+ export function buildDistributionImagesStatement({
650
+ distributionLock,
651
+ imageLock,
652
+ images,
653
+ channel,
654
+ releaseSequence,
655
+ releaseSourceCommit,
656
+ runId,
657
+ runAttempt,
658
+ issuedAt,
659
+ expiresAt,
660
+ maxTtlSeconds,
661
+ schemas,
662
+ }) {
663
+ if (!schemas?.parseDistributionImagesStatement || typeof schemas.distributionLockDigest !== 'function') {
664
+ throw new Error('trusted kernel distribution schemas are required');
665
+ }
666
+ if (
667
+ distributionLock?.distributionId !== imageLock?.distributionId ||
668
+ typeof distributionLock.inputHash !== 'string'
669
+ ) {
670
+ throw new Error(
671
+ 'distribution completeness requires the distribution and image locks of ONE edition',
672
+ );
673
+ }
674
+ const label = `distribution completeness for ${imageLock.distributionId}`;
675
+ if (!COMMIT_PATTERN.test(releaseSourceCommit ?? '')) {
676
+ throw new Error(`${label} requires an exact 40-hex release source commit`);
677
+ }
678
+ if (!Number.isSafeInteger(releaseSequence) || releaseSequence < 0) {
679
+ throw new Error(`${label} requires a non-negative release sequence`);
680
+ }
681
+ if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0) {
682
+ throw new Error(`${label} requires a positive run attempt`);
683
+ }
684
+ assertIssuanceWindow({ issuedAt, expiresAt, maxTtlSeconds, label });
685
+
686
+ if (!Array.isArray(images) || images.length === 0) {
687
+ throw new Error(`${label} has an empty image set`);
688
+ }
689
+ // The COMPLETE set, exactly: the verifier rebuilds this list from the lock
690
+ // and refuses anything that is missing, extra, or out of canonical order.
691
+ // Refusing here as well means an incomplete set can never be SIGNED, rather
692
+ // than being signed and rejected days later at a customer install.
693
+ const expectedIdentities = imageLock.subjects
694
+ .map((subject) =>
695
+ completenessImageKey({
696
+ subjectKind: subject.kind,
697
+ subjectId: subject.id,
698
+ imageRepository: subject.imageRef,
699
+ manifestDigest: subject.imageDigest,
700
+ }),
701
+ )
702
+ .sort(compareStrings);
703
+ const actualIdentities = images
704
+ .map((image) => completenessImageKey(image))
705
+ .sort(compareStrings);
706
+ if (
707
+ new Set(actualIdentities).size !== actualIdentities.length ||
708
+ canonicalJson(expectedIdentities) !== canonicalJson(actualIdentities)
709
+ ) {
710
+ throw new Error(
711
+ `${label} image set is not the exact complete subject set of images.lock.json`,
712
+ );
713
+ }
714
+ const ordered = [...images].sort(compareCompletenessImages);
715
+ if (canonicalJson(images) !== canonicalJson(ordered)) {
716
+ throw new Error(`${label} image set is not canonically ordered`);
717
+ }
718
+
719
+ // ONE owner for "what digest names this lock": the verifier compares against
720
+ // `normalizedImageLockDigest(imageLock)`, so a second inline computation here
721
+ // would be the exact drift this binding exists to catch.
722
+ const normalizedLockDigest = normalizedImageLockDigest(imageLock);
723
+ const statement = {
724
+ _type: 'https://in-toto.io/Statement/v1',
725
+ subject: [
726
+ {
727
+ name: `xema-distribution/${imageLock.distributionId}`,
728
+ digest: { sha256: normalizedLockDigest.slice('sha256:'.length) },
729
+ },
730
+ ],
731
+ predicateType: schemas.DISTRIBUTION_IMAGES_PREDICATE_TYPE,
732
+ predicate: {
733
+ schemaVersion: 2,
734
+ channel,
735
+ lockDigest: schemas.distributionLockDigest(distributionLock),
736
+ distributionId: imageLock.distributionId,
737
+ distributionInputHash: distributionLock.inputHash,
738
+ normalizedImageLockDigest: normalizedLockDigest,
739
+ releaseSequence,
740
+ releaseSourceCommit,
741
+ runId,
742
+ runAttempt,
743
+ nonce: derivedNonce('xema.distribution-completeness-evidence.nonce.v1', [
744
+ runId,
745
+ runAttempt,
746
+ imageLock.distributionId,
747
+ releaseSourceCommit,
748
+ releaseSequence,
749
+ normalizedLockDigest,
750
+ ]),
751
+ issuedAt,
752
+ expiresAt,
753
+ images: ordered.map((image) => ({
754
+ subjectKind: image.subjectKind,
755
+ subjectId: image.subjectId,
756
+ imageRepository: image.imageRepository,
757
+ manifestDigest: image.manifestDigest,
758
+ producerStatementDigest: image.producerStatementDigest,
759
+ producerBundleDigest: image.producerBundleDigest,
760
+ })),
761
+ },
762
+ };
763
+ return schemas.parseDistributionImagesStatement(statement);
764
+ }
765
+
766
+ /**
767
+ * The delivery-entitlement statement: "this customer may take THIS release,
768
+ * in THIS mode, to THIS target, once, before it expires."
769
+ *
770
+ * The mode/targetRegistry pairing is enforced by the kernel schema's own
771
+ * `superRefine` (mirror REQUIRES a target registry; shared and airgap FORBID
772
+ * one) at the `parse` below — which happens here, in the builder, BEFORE any
773
+ * signing. Restating the rule in this file would be a second copy of a closed
774
+ * decision that already has an owner.
775
+ */
776
+ export function buildImageDeliveryEntitlementStatement({
777
+ distributionLock,
778
+ distributionId,
779
+ completenessStatementDigest,
780
+ customerId,
781
+ mode,
782
+ targetRegistry,
783
+ idempotencyKey,
784
+ runId,
785
+ runAttempt,
786
+ issuedAt,
787
+ expiresAt,
788
+ maxTtlSeconds,
789
+ schemas,
790
+ }) {
791
+ if (!schemas?.parseImageDeliveryEntitlementStatement || typeof schemas.distributionLockDigest !== 'function') {
792
+ throw new Error('trusted kernel distribution schemas are required');
793
+ }
794
+ assertCanonicalDistributionId(distributionId);
795
+ if (distributionLock?.distributionId !== distributionId) {
796
+ throw new Error(`delivery entitlement for ${String(customerId)} requires the distribution lock of ${distributionId}`);
797
+ }
798
+ const label = `delivery entitlement for ${String(customerId)}`;
799
+ if (!SHA256_DIGEST_PATTERN.test(completenessStatementDigest ?? '')) {
800
+ throw new Error(
801
+ `${label} requires the exact sha256 digest of the completeness statement`,
802
+ );
803
+ }
804
+ if (targetRegistry !== null && typeof targetRegistry !== 'string') {
805
+ throw new Error(
806
+ `${label} requires an explicit target registry or an explicit null`,
807
+ );
808
+ }
809
+ if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0) {
810
+ throw new Error(`${label} requires a positive run attempt`);
811
+ }
812
+ assertIssuanceWindow({ issuedAt, expiresAt, maxTtlSeconds, label });
813
+
814
+ const statement = {
815
+ _type: 'https://in-toto.io/Statement/v1',
816
+ subject: [
817
+ {
818
+ name: `xema-distribution/${distributionId}`,
819
+ digest: {
820
+ sha256: completenessStatementDigest.slice('sha256:'.length),
821
+ },
822
+ },
823
+ ],
824
+ predicateType: schemas.IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
825
+ predicate: {
826
+ schemaVersion: 2,
827
+ customerId,
828
+ distributionId,
829
+ lockDigest: schemas.distributionLockDigest(distributionLock),
830
+ // Present-and-nullable: null states the lock carries no catalogue; the lock digest binds the lock either way.
831
+ catalogueDigest: distributionLock.catalogueDigest ?? null,
832
+ // The field the lock carries, never both: a catalogue-free lock's catalogueContentDigest.
833
+ ...(distributionLock.catalogueContentDigest === undefined ? {} : { catalogueContentDigest: distributionLock.catalogueContentDigest }),
834
+ distributionStatementDigest: completenessStatementDigest,
835
+ mode,
836
+ targetRegistry,
837
+ nonce: derivedNonce('xema.image-delivery-entitlement.nonce.v1', [
838
+ runId,
839
+ runAttempt,
840
+ customerId,
841
+ distributionId,
842
+ completenessStatementDigest,
843
+ idempotencyKey,
844
+ ]),
845
+ idempotencyKey,
846
+ issuedAt,
847
+ expiresAt,
848
+ },
849
+ };
850
+ return schemas.parseImageDeliveryEntitlementStatement(statement);
851
+ }
852
+
853
+ /**
854
+ * `evidence-set.json` — the one document the offline verifier is handed.
855
+ *
856
+ * It can only be assembled once completeness EXISTS: the kernel schema requires
857
+ * the `completeness` descriptor, so there is no such thing as a half-assembled
858
+ * set on disk. That ordering is the design, not an accident of this function.
859
+ */
860
+ export function buildDistributionImageEvidenceSet({
861
+ distributionId,
862
+ producerEvidence,
863
+ completeness,
864
+ schemas,
865
+ }) {
866
+ if (!schemas?.parseDistributionImageEvidenceSet) {
867
+ throw new Error('trusted kernel distribution schemas are required');
868
+ }
869
+ assertCanonicalDistributionId(distributionId);
870
+ return schemas.parseDistributionImageEvidenceSet({
871
+ schemaVersion: 1,
872
+ distributionId,
873
+ producerEvidence,
874
+ completeness,
875
+ });
876
+ }
877
+
878
+ // ─────────────────────────────────────────────────────────────────────────────
879
+ // The signing path shared by the completeness and entitlement producers
880
+ // ─────────────────────────────────────────────────────────────────────────────
881
+
882
+ /**
883
+ * The pinned cosign version is read from the COMMITTED trust policy, never from
884
+ * a literal in this file: the policy is what the offline verifier enforces, so
885
+ * a producer signing with a different cosign than the verifier trusts must fail
886
+ * at signing time, not at delivery.
887
+ */
888
+ export function assertPinnedCosignVersion(output, expectedVersion) {
889
+ if (!/^v\d+\.\d+\.\d+$/.test(expectedVersion ?? '')) {
890
+ throw new Error('the trust policy declares no exact cosign version');
891
+ }
892
+ const match = /\bGitVersion:\s*(v\d+\.\d+\.\d+)\b/.exec(String(output));
893
+ if (!match || match[1] !== expectedVersion) {
894
+ throw new Error(`cosign version does not match pinned ${expectedVersion}`);
895
+ }
896
+ return match[1];
897
+ }
898
+
899
+ /**
900
+ * The trust policy the data root declares, held to the data root's pin of it.
901
+ * Producers take their cosign version and TTL bounds from this policy, the
902
+ * same one the verifier verifies under.
903
+ */
904
+ export function loadPinnedTrustPolicy({ dataRoot, schemas }) {
905
+ return loadPinnedDeclaredTrustPolicy({
906
+ dataRoot,
907
+ declaration: editionWorkspaceConfig().evidenceTrustPolicy,
908
+ schemas,
909
+ });
910
+ }
911
+
912
+ function assertSignableStatement({ statement, root, certificateWorkflowSha, predicateType }) {
913
+ assertAbsolutePath(root, 'signing root');
914
+ if (!COMMIT_PATTERN.test(certificateWorkflowSha ?? '')) {
915
+ throw new Error(
916
+ 'signing requires the exact 40-hex commit of the signing workflow',
917
+ );
918
+ }
919
+ if (statement.predicateType !== predicateType) {
920
+ throw new Error(
921
+ `refusing to stage a ${statement.predicateType} statement as ${predicateType}`,
922
+ );
923
+ }
924
+ }
925
+
926
+ /**
927
+ * Write the canonical bytes of ONE statement for signing and return its
928
+ * signing plan entry. This library never signs: the caller runs
929
+ * `cosign attest-blob` and hands the bundle to {@link acceptSignedEvidenceStatement}.
930
+ */
931
+ export function stageEvidenceStatement({
932
+ statement,
933
+ root,
934
+ statementPath,
935
+ bundlePath,
936
+ signer,
937
+ certificateWorkflowSha,
938
+ predicateType,
939
+ }) {
940
+ assertSignableStatement({ statement, root, certificateWorkflowSha, predicateType });
941
+ const absoluteStatement = join(root, statementPath);
942
+ mkdirSync(dirname(absoluteStatement), { recursive: true, mode: 0o700 });
943
+ mkdirSync(dirname(join(root, bundlePath)), { recursive: true, mode: 0o700 });
944
+ writeFileSync(absoluteStatement, canonicalJsonBytes(statement), { flag: 'wx', mode: 0o600 });
945
+ return {
946
+ schemaVersion: 1,
947
+ root,
948
+ predicateType,
949
+ signer,
950
+ certificateWorkflowSha,
951
+ statements: [{ statementPath, bundlePath }],
952
+ };
953
+ }
954
+
955
+ /**
956
+ * Accept the signed bundle of a staged statement into `sealingRoot`. `statement`
957
+ * is re-derived by the caller; the staged bytes must be exactly its canonical
958
+ * bytes and the bundle must carry exactly them. The canonical statement and
959
+ * bundle are WRITTEN to `sealingRoot` (the staging is only read) and cosign
960
+ * verifies the bundle there, against the declared signer at the exact workflow
961
+ * commit.
962
+ */
963
+ export function acceptSignedEvidenceStatement({
964
+ statement,
965
+ root,
966
+ sealingRoot,
967
+ statementPath,
968
+ bundlePath,
969
+ signer,
970
+ certificateWorkflowSha,
971
+ predicateType,
972
+ runCosign,
973
+ }) {
974
+ assertSignableStatement({ statement, root, certificateWorkflowSha, predicateType });
975
+ assertAbsolutePath(sealingRoot, 'sealing root');
976
+ if (typeof runCosign !== 'function') {
977
+ throw new Error('a cosign runner is required');
978
+ }
979
+ const statementBytes = canonicalJsonBytes(statement);
980
+ if (!readFileSync(join(root, statementPath)).equals(statementBytes)) {
981
+ throw new Error(
982
+ `staged statement ${statementPath} is not the statement these inputs build`,
983
+ );
984
+ }
985
+ const bundleBytes = canonicalSignedBundleBytes({ statementBytes, bundlePath: join(root, bundlePath) });
986
+ mkdirSync(dirname(join(sealingRoot, statementPath)), { recursive: true, mode: 0o755 });
987
+ mkdirSync(dirname(join(sealingRoot, bundlePath)), { recursive: true, mode: 0o755 });
988
+ writeFileSync(join(sealingRoot, statementPath), statementBytes, { flag: 'wx', mode: 0o644 });
989
+ writeFileSync(join(sealingRoot, bundlePath), bundleBytes, { flag: 'wx', mode: 0o644 });
990
+ // Verified where it is sealed: the bytes cosign accepts are the bytes kept.
991
+ runCosign(
992
+ cosignVerifyAttestationArgs({
993
+ bundlePath: join(sealingRoot, bundlePath),
994
+ signer,
995
+ certificateWorkflowSha,
996
+ predicateType,
997
+ }),
998
+ );
999
+ return {
1000
+ statementPath,
1001
+ statementDigest: sha256Digest(statementBytes),
1002
+ bundlePath,
1003
+ bundleDigest: sha256Digest(bundleBytes),
1004
+ bundleMediaType: BUNDLE_MEDIA_TYPE,
1005
+ };
1006
+ }
1007
+
1008
+ // ─────────────────────────────────────────────────────────────────────────────
1009
+ // The CLI contract shared by this family's three entrypoints
1010
+ // ─────────────────────────────────────────────────────────────────────────────
1011
+
1012
+ /**
1013
+ * Strict `--flag value` parsing.
1014
+ *
1015
+ * One owner for the three entrypoints in this family so they cannot drift in
1016
+ * how they refuse an unknown flag, a repeated single-valued flag, or a missing
1017
+ * value. A typo'd flag that parses as "not provided" is exactly how a producer
1018
+ * silently signs with a default nobody chose.
1019
+ */
1020
+ export function parseEvidenceCliFlags(argv, { single = [], repeated = [] }) {
1021
+ const singles = new Set(single);
1022
+ const repeats = new Set(repeated);
1023
+ const flags = new Map();
1024
+ for (let index = 0; index < argv.length; index += 2) {
1025
+ const flag = argv[index];
1026
+ if (typeof flag !== 'string' || !flag.startsWith('--')) {
1027
+ throw new Error(`unexpected argument "${String(flag)}"`);
1028
+ }
1029
+ const name = flag.slice(2);
1030
+ const value = argv[index + 1];
1031
+ if (value === undefined || value.startsWith('--')) {
1032
+ throw new Error(`--${name} requires a value`);
1033
+ }
1034
+ if (repeats.has(name)) {
1035
+ flags.set(name, [...(flags.get(name) ?? []), value]);
1036
+ continue;
1037
+ }
1038
+ if (!singles.has(name)) {
1039
+ throw new Error(`unknown option "--${name}"`);
1040
+ }
1041
+ if (flags.has(name)) {
1042
+ throw new Error(`--${name} was given twice`);
1043
+ }
1044
+ flags.set(name, value);
1045
+ }
1046
+ return {
1047
+ optional: (name) => flags.get(name),
1048
+ many: (name) => flags.get(name) ?? [],
1049
+ require(name) {
1050
+ const value = flags.get(name);
1051
+ if (value === undefined) {
1052
+ throw new Error(`--${name} is required`);
1053
+ }
1054
+ return value;
1055
+ },
1056
+ requireMany(name) {
1057
+ const values = flags.get(name);
1058
+ if (!Array.isArray(values) || values.length === 0) {
1059
+ throw new Error(`--${name} is required at least once`);
1060
+ }
1061
+ return values;
1062
+ },
1063
+ };
1064
+ }