@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,664 @@
1
+ // OCI registry read transport for delivery-time verification, done by the
2
+ // pinned `oras` CLI.
3
+ //
4
+ // This module owns one concern: getting BYTES out of a real registry and
5
+ // proving they are the bytes that were asked for. It does not know what a
6
+ // distribution is or why anything is being fetched. The credential
7
+ // DECLARATION contract lives in `registry-credentials.mjs`.
8
+ //
9
+ // Every read is fail-closed, and there is no retry and no fallback:
10
+ // * the oras binary is a TRUSTED EXECUTABLE: on a data root that declares an
11
+ // evidence-trust pin its bytes must hash to one of the pin's
12
+ // `trustedOrasBinaryDigests`, and it runs from a private copy of exactly
13
+ // those bytes; its version must be the one this module's parsing is written
14
+ // against;
15
+ // * oras runs under a scrubbed environment (no ambient proxy, config or
16
+ // credential variable reaches it) and an EMPTY registry config, so
17
+ // ambient docker/oras credentials are never used and an authenticated
18
+ // registry never degrades into an anonymous read;
19
+ // * the token travels on stdin only (`--password-stdin`), never in argv;
20
+ // * before oras ever receives a credential, the registry's own `/v2/`
21
+ // challenge is probed and a token realm on another origin is refused;
22
+ // * a manifest or blob is fetched in ONE call (`--descriptor --output <file>`:
23
+ // the descriptor on stdout, the raw content in a scratch file). Its bytes
24
+ // must hash to the digest the registry described (and, for a digest read,
25
+ // to the requested digest), or the read throws; the manifest media type is
26
+ // the descriptor's, never the manifest body's optional `mediaType` (Helm's
27
+ // has none);
28
+ // * declared size, on-disk size and read length are all held to the cap
29
+ // BEFORE the content is read into memory;
30
+ // * oras runs asynchronously, so a caller's worker pool really runs in parallel;
31
+ // * every call of one registry shares one empty registry config for the run;
32
+ // * a registry status is read only from oras's own error forms; any other
33
+ // failure carries no status and is thrown as undecided;
34
+ // * `--plain-http` and `--insecure` are never passed.
35
+
36
+ import { createHash, randomUUID } from 'node:crypto';
37
+ import {
38
+ mkdtempSync,
39
+ readFileSync,
40
+ rmSync,
41
+ statSync,
42
+ unlinkSync,
43
+ writeFileSync,
44
+ } from 'node:fs';
45
+ import { tmpdir } from 'node:os';
46
+ import { isAbsolute, join } from 'node:path';
47
+
48
+ import {
49
+ assertOrasVersion,
50
+ locateOras,
51
+ orasScrubbedEnvironment,
52
+ REFERRERS_DISTRIBUTION_SPEC,
53
+ } from '@xemahq/oci-closure';
54
+
55
+ import { editionWorkspaceConfig } from './edition-config.mjs';
56
+ import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
57
+ import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
58
+ import { readEvidenceTrustPin } from './evidence-trust-pin.mjs';
59
+ import { runExecutable } from './oras-process.mjs';
60
+ import {
61
+ OCI_INDEX_MEDIA_TYPES,
62
+ REGISTRY_CREDENTIALS_FILE,
63
+ } from './registry-credentials.mjs';
64
+ import {
65
+ assertPreparedExecutable,
66
+ prepareDigestPinnedExecutable,
67
+ } from './trusted-executable.mjs';
68
+
69
+ /** The refusal of a Bearer token realm on another origin than the registry's. */
70
+ export const REGISTRY_TOKEN_REALM_CROSS_HOST = 'REGISTRY_TOKEN_REALM_CROSS_HOST';
71
+
72
+ export class RegistryTokenRealmError extends Error {
73
+ /** @param {string} authority @param {string} realmOrigin */
74
+ constructor(authority, realmOrigin) {
75
+ super(
76
+ `registry ${authority} advertised token realm "${realmOrigin}", which is not its own origin; ` +
77
+ 'the pull credential is never sent to a third-party host',
78
+ );
79
+ this.name = 'RegistryTokenRealmError';
80
+ this.code = REGISTRY_TOKEN_REALM_CROSS_HOST;
81
+ }
82
+ }
83
+
84
+ const ORAS_RUN_TIMEOUT_MS = 120_000;
85
+ const ORAS_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
86
+
87
+ const ACCEPTED_MANIFEST_MEDIA_TYPES = Object.freeze([
88
+ ...OCI_INDEX_MEDIA_TYPES,
89
+ 'application/vnd.oci.image.manifest.v1+json',
90
+ 'application/vnd.docker.distribution.manifest.v2+json',
91
+ ]);
92
+
93
+ const REGISTRY_REQUEST_TIMEOUT_MS = 30_000;
94
+ const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
95
+ const MAX_DESCRIPTOR_BYTES = 64 * 1024;
96
+ const MAX_CONFIG_BLOB_BYTES = 4 * 1024 * 1024;
97
+ const MAX_ERROR_DETAIL_CHARS = 512;
98
+
99
+ function sha256DigestOf(bytes) {
100
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
101
+ }
102
+
103
+ /**
104
+ * The oras digests the configured data root trusts: `trustedOrasBinaryDigests`
105
+ * of its evidence-trust pin. `null` when the data root declares no evidence
106
+ * trust at all (`evidenceTrustPolicy: null`):
107
+ * such a data root has no reviewed pin to hold a binary to.
108
+ */
109
+ export function configuredOrasTrust() {
110
+ const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
111
+ if (declaration.policy === null) {return null;}
112
+ return readEvidenceTrustPin({ dataRoot: configuredDataRoot(), declaration })
113
+ .trustedOrasBinaryDigests;
114
+ }
115
+
116
+ /** Each notice sink is told once: the default `console.warn` once per process, a test's own sink once per test. */
117
+ const noticesGiven = new WeakSet();
118
+
119
+ /**
120
+ * Prepare the oras binary for use. With `trustedDigests` (an array) the binary
121
+ * must hash to one of them and a private copy of exactly those bytes is what
122
+ * runs; an empty list trusts no binary. With `trustedDigests: null` (no
123
+ * evidence trust in force: a publisher's run with no data root, or a data root
124
+ * that declares none) the binary is held to its version only,
125
+ * and the run says so once.
126
+ *
127
+ * @returns {{version: string, pinned: boolean, path: () => string, dispose: () => void}}
128
+ */
129
+ export function prepareTrustedOras({ env = process.env, trustedDigests, notice = console.warn } = {}) {
130
+ if (trustedDigests !== null && !Array.isArray(trustedDigests)) {
131
+ throw new Error('the trusted oras digests are a list, or null for a data root that declares no evidence trust');
132
+ }
133
+ const located = locateOras({ env });
134
+ const home = mkdtempSync(join(tmpdir(), 'xema-oras-home-'));
135
+ try {
136
+ if (trustedDigests === null) {
137
+ const version = assertOrasVersion(located, home);
138
+ if (!noticesGiven.has(notice)) {
139
+ noticesGiven.add(notice);
140
+ notice(
141
+ `[oras] ${located} is held to its version (${version}) only: no evidence-trust pin is in force ` +
142
+ '(a publisher run, or a data root that declares none), so there are no trustedOrasBinaryDigests to hold it to.',
143
+ );
144
+ }
145
+ return {
146
+ version,
147
+ pinned: false,
148
+ home,
149
+ path: () => located,
150
+ dispose: () => rmSync(home, { recursive: true, force: true }),
151
+ };
152
+ }
153
+ let version;
154
+ const handle = prepareDigestPinnedExecutable({
155
+ executablePath: located,
156
+ trustedDigests,
157
+ label: 'oras',
158
+ verifyVersion: (privateBinary) => {
159
+ version = assertOrasVersion(privateBinary, home);
160
+ },
161
+ });
162
+ return {
163
+ version,
164
+ pinned: true,
165
+ home,
166
+ path: () => assertPreparedExecutable(handle, 'oras'),
167
+ dispose: () => {
168
+ handle.dispose();
169
+ rmSync(home, { recursive: true, force: true });
170
+ },
171
+ };
172
+ } catch (error) {
173
+ rmSync(home, { recursive: true, force: true });
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ /** The default runner: the trusted oras of the configured data root, under the scrubbed environment. */
179
+ function defaultRunner(trust = configuredOrasTrust) {
180
+ let prepared;
181
+ return async (args, { stdin, cwd } = {}) => {
182
+ if (prepared === undefined) {
183
+ prepared = prepareTrustedOras({ trustedDigests: trust() });
184
+ process.once('exit', prepared.dispose);
185
+ }
186
+ try {
187
+ return await runExecutable(prepared.path(), args, {
188
+ input: stdin,
189
+ ...(cwd === undefined ? {} : { cwd }),
190
+ env: orasScrubbedEnvironment(prepared.home),
191
+ timeoutMs: ORAS_RUN_TIMEOUT_MS,
192
+ maxBufferBytes: ORAS_MAX_BUFFER_BYTES,
193
+ });
194
+ } catch (error) {
195
+ const stderr = error?.stderr ? error.stderr.toString('utf8') : '';
196
+ const wrapped = new Error(stderr.trim() || (error instanceof Error ? error.message : String(error)));
197
+ // Only what oras itself wrote is offered as a registry answer; a spawn
198
+ // failure, a timeout or a signal carries no stderr and so no status.
199
+ wrapped.stderr = stderr;
200
+ throw wrapped;
201
+ }
202
+ };
203
+ }
204
+
205
+ function parseWwwAuthenticate(header) {
206
+ if (typeof header !== 'string' || !/^Bearer\s/i.test(header)) {return null;}
207
+ const parameters = new Map();
208
+ const pattern = /([a-zA-Z_]+)="((?:[^"\\]|\\.)*)"/g;
209
+ let match = pattern.exec(header);
210
+ while (match) {
211
+ parameters.set(match[1], match[2].replace(/\\(.)/g, '$1'));
212
+ match = pattern.exec(header);
213
+ }
214
+ const realm = parameters.get('realm');
215
+ if (!realm) {return null;}
216
+ return { realm };
217
+ }
218
+
219
+ const ORAS_REGISTRY_ERROR_PREFIX = 'Error response from registry: ';
220
+ const ORAS_ERROR_PREFIX = 'Error: ';
221
+
222
+ /**
223
+ * The HTTP status a registry answered with; null when the failure is not a
224
+ * registry answer.
225
+ *
226
+ * oras 1.3.4 has no structured error output and exits 1 for every failure, so
227
+ * the status is read from its stderr, and ONLY from the forms oras itself
228
+ * prints (captured against a registry; see oras.test.mjs). The FIRST line of
229
+ * stderr must be oras's own error line, and within it:
230
+ * * `Error response from registry: … response status code <NNN>: …` carries
231
+ * the registry's status itself;
232
+ * * `Error response from registry: … <ref>: not found` and
233
+ * `Error: … <ref>: not found`, ending the line with the EXACT reference that
234
+ * was asked for, are oras's resolution of a 404;
235
+ * * `Error response from registry: name unknown: …` is the registry's error
236
+ * code for a missing repository, in the position oras prints it.
237
+ * Everything else is null: text from a wrapper, a shell (`oras: not found`,
238
+ * `Permission denied`), a proxy or a transport failure is not a registry
239
+ * answer, whatever words it contains, and neither is a status-like word inside
240
+ * a reference or a quoted string. A null status is thrown by the caller as an
241
+ * UNDECIDED failure; it is never read as "absent".
242
+ */
243
+ export function statusOfOrasFailure(text, { ref = '' } = {}) {
244
+ const [line] = String(text).split('\n');
245
+ const fromRegistry = line.startsWith(ORAS_REGISTRY_ERROR_PREFIX);
246
+ if (!fromRegistry && !line.startsWith(ORAS_ERROR_PREFIX)) {return null;}
247
+ if (fromRegistry) {
248
+ // The status oras reports sits after the quoted request URL; a reference
249
+ // that spells `response status code 404` is inside the quotes.
250
+ const explicit = /\bresponse status code (\d{3})\b/.exec(line.replace(/"(?:[^"\\]|\\.)*"/g, '""'));
251
+ if (explicit) {return Number(explicit[1]);}
252
+ if (/^name unknown: /.test(line.slice(ORAS_REGISTRY_ERROR_PREFIX.length))) {return 404;}
253
+ }
254
+ if (ref && line.endsWith(` ${ref}: not found`)) {return 404;}
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * ONE oras working directory per registry authority per process: the (empty)
260
+ * registry config every call of that registry points at, and the scratch
261
+ * files a fetch writes its content to. The config never holds a credential
262
+ * (that goes to oras on stdin, per call), so sharing it carries no secret; it
263
+ * replaces a temp directory made and removed around every single call.
264
+ */
265
+ const registrySessions = new Map();
266
+
267
+ function registrySession(authority) {
268
+ let session = registrySessions.get(authority);
269
+ if (session === undefined) {
270
+ const directory = mkdtempSync(join(tmpdir(), 'xema-oras-'));
271
+ const configPath = join(directory, 'registry-config.json');
272
+ writeFileSync(configPath, '');
273
+ session = { directory, configPath };
274
+ registrySessions.set(authority, session);
275
+ if (registrySessions.size === 1) {
276
+ process.once('exit', disposeRegistrySessions);
277
+ }
278
+ }
279
+ return session;
280
+ }
281
+
282
+ /** Remove every registry session directory; called at process exit, and by tests. */
283
+ export function disposeRegistrySessions() {
284
+ for (const { directory } of registrySessions.values()) {
285
+ rmSync(directory, { recursive: true, force: true });
286
+ }
287
+ registrySessions.clear();
288
+ }
289
+
290
+ /**
291
+ * An OCI registry client (reads, and `attach`) bound to ONE registry authority and ONE
292
+ * resolved credential, doing every operation with the pinned oras.
293
+ */
294
+ export class OrasRegistry {
295
+ #credential;
296
+ #run;
297
+ #fetchImpl;
298
+ #username = null;
299
+ #token = null;
300
+ #realmCheck = null;
301
+
302
+ /**
303
+ * `run` replaces the oras invocation (tests). Without it the trusted oras of
304
+ * the configured data root runs; `trust` states the trusted digests instead
305
+ * of reading them from the data root's pin.
306
+ */
307
+ constructor({ credential, run, trust, fetchImpl = globalThis.fetch }) {
308
+ if (typeof credential?.authority !== 'string') {
309
+ throw new Error('an oras registry requires a resolved credential');
310
+ }
311
+ if (typeof fetchImpl !== 'function') {
312
+ throw new Error('an oras registry requires a fetch implementation for the token-realm check');
313
+ }
314
+ this.#credential = credential;
315
+ this.#run = run ?? defaultRunner(trust);
316
+ this.#fetchImpl = fetchImpl;
317
+ if (credential.access === 'authenticated') {
318
+ const decoded = Buffer.from(credential.basicAuth ?? '', 'base64').toString('utf8');
319
+ const colon = decoded.indexOf(':');
320
+ if (colon <= 0 || colon === decoded.length - 1) {
321
+ throw new Error(`registry ${credential.authority} is authenticated but its credential is incomplete`);
322
+ }
323
+ this.#username = decoded.slice(0, colon);
324
+ this.#token = decoded.slice(colon + 1);
325
+ }
326
+ }
327
+
328
+ get authority() {
329
+ return this.#credential.authority;
330
+ }
331
+
332
+ /**
333
+ * The Bearer token realm must be the registry's OWN origin. Checked once,
334
+ * with an unauthenticated probe, BEFORE oras is handed the credential: a
335
+ * registry that points its realm at another host would otherwise receive the
336
+ * pull token (a credential exfiltration primitive).
337
+ */
338
+ #ensureRealmIsSameOrigin() {
339
+ if (this.#credential.access !== 'authenticated') {return Promise.resolve();}
340
+ this.#realmCheck ??= this.#probeRealm();
341
+ return this.#realmCheck;
342
+ }
343
+
344
+ async #probeRealm() {
345
+ const origin = `https://${this.authority}`;
346
+ let response;
347
+ try {
348
+ response = await this.#fetchImpl(`${origin}/v2/`, {
349
+ method: 'GET',
350
+ redirect: 'manual',
351
+ headers: { 'user-agent': 'xema-edition-platform-verifier/1' },
352
+ signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
353
+ });
354
+ } catch (error) {
355
+ throw new Error(
356
+ `registry request to ${origin}/v2/ failed: ${error instanceof Error ? error.message : String(error)}`,
357
+ );
358
+ }
359
+ if (response.status === 200) {return;}
360
+ if (response.status !== 401) {
361
+ throw new Error(`registry ${this.authority} answered its /v2/ probe with ${response.status}`);
362
+ }
363
+ const challenge = parseWwwAuthenticate(response.headers.get('www-authenticate'));
364
+ if (!challenge) {return;}
365
+ let url;
366
+ try {
367
+ url = new URL(challenge.realm);
368
+ } catch {
369
+ throw new Error(
370
+ `registry ${this.authority} advertised a token realm that is not an absolute URL`,
371
+ );
372
+ }
373
+ if (url.origin !== origin) {
374
+ throw new RegistryTokenRealmError(this.authority, url.origin);
375
+ }
376
+ }
377
+
378
+ #scrub(text) {
379
+ return this.#token ? text.split(this.#token).join('***') : text;
380
+ }
381
+
382
+ /** One oras invocation under this registry's shared empty config; returns stdout bytes. */
383
+ async #oras(subcommand, flags, ref, { cwd, operands = [] } = {}) {
384
+ await this.#ensureRealmIsSameOrigin();
385
+ const { configPath } = registrySession(this.authority);
386
+ const args = [...subcommand, '--registry-config', configPath];
387
+ let stdin;
388
+ if (this.#credential.access === 'authenticated') {
389
+ args.push('--username', this.#username, '--password-stdin');
390
+ stdin = this.#token;
391
+ }
392
+ args.push(...flags, ref, ...operands);
393
+ try {
394
+ const out = await this.#run(args, { stdin, ...(cwd === undefined ? {} : { cwd }) });
395
+ return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
396
+ } catch (error) {
397
+ throw this.#failure(ref, error);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * ONE oras call that returns the descriptor (stdout) AND the raw content
403
+ * (written to a scratch file by `--output`): the call that used to be a
404
+ * descriptor round trip followed by a content round trip. The content is
405
+ * read into memory only after both its declared and its on-disk size are
406
+ * inside `maxBytes`.
407
+ */
408
+ async #fetchContent(subcommand, ref, maxBytes, acceptDescriptor) {
409
+ const { directory } = registrySession(this.authority);
410
+ const outputPath = join(directory, `content-${randomUUID()}`);
411
+ try {
412
+ const described = await this.#oras(subcommand, ['--descriptor', '--output', outputPath], ref);
413
+ const descriptor = this.#parseDescriptor(described, ref);
414
+ acceptDescriptor(descriptor);
415
+ if (descriptor.size > maxBytes) {
416
+ throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${maxBytes}`);
417
+ }
418
+ const onDisk = statSync(outputPath).size;
419
+ if (onDisk > maxBytes) {
420
+ throw new Error(`${ref} returned more than ${maxBytes} bytes`);
421
+ }
422
+ const bytes = readFileSync(outputPath);
423
+ const actual = sha256DigestOf(bytes);
424
+ if (actual !== descriptor.digest) {
425
+ throw new Error(`${ref} returned content addressed ${actual}, not the digest ${descriptor.digest} it described`);
426
+ }
427
+ return { descriptor, bytes };
428
+ } finally {
429
+ try {
430
+ unlinkSync(outputPath);
431
+ } catch {
432
+ // No content file exists when the fetch itself failed; the failure is already thrown.
433
+ }
434
+ }
435
+ }
436
+
437
+ #failure(ref, error) {
438
+ // The status comes from oras's stderr ALONE. A failure with no stderr (the
439
+ // binary did not start, timed out, was killed) is local, and its message is
440
+ // shown but never parsed for a status.
441
+ const stderr = this.#scrub(typeof error?.stderr === 'string' ? error.stderr.trim() : '');
442
+ const status = stderr ? statusOfOrasFailure(stderr, { ref }) : null;
443
+ const raw = this.#scrub(error instanceof Error ? error.message : String(error));
444
+ const detail = (stderr || raw).slice(0, MAX_ERROR_DETAIL_CHARS).trim();
445
+ if (status === null) {
446
+ return new Error(`registry request to ${ref} failed: ${detail}`);
447
+ }
448
+ const hint =
449
+ (status === 401 || status === 403) && this.#credential.access === 'anonymous'
450
+ ? ` — registry "${this.authority}" is declared "anonymous" in ${REGISTRY_CREDENTIALS_FILE}; if its packages are private, declare it "authenticated"`
451
+ : '';
452
+ const failure = new Error(
453
+ `${ref} returned HTTP ${status}${hint}${detail ? `: ${detail}` : ''}`,
454
+ );
455
+ failure.status = status;
456
+ return failure;
457
+ }
458
+
459
+ #ref(repository) {
460
+ return `${this.authority}/${repository}`;
461
+ }
462
+
463
+ #parseDescriptor(bytes, ref) {
464
+ if (bytes.length > MAX_DESCRIPTOR_BYTES) {
465
+ throw new Error(`${ref} descriptor is larger than ${MAX_DESCRIPTOR_BYTES} bytes`);
466
+ }
467
+ let descriptor;
468
+ try {
469
+ descriptor = JSON.parse(bytes.toString('utf8'));
470
+ } catch (error) {
471
+ throw new Error(`${ref} descriptor is not JSON: ${error.message}`);
472
+ }
473
+ if (
474
+ typeof descriptor !== 'object' ||
475
+ descriptor === null ||
476
+ typeof descriptor.mediaType !== 'string' ||
477
+ !SHA256_DIGEST_PATTERN.test(descriptor.digest) ||
478
+ !Number.isSafeInteger(descriptor.size) ||
479
+ descriptor.size < 0
480
+ ) {
481
+ throw new Error(`${ref} did not return a usable descriptor`);
482
+ }
483
+ return descriptor;
484
+ }
485
+
486
+ /**
487
+ * ATTACH `files` (paths relative to `cwd`, each with its media type) as an artifact of `artifactType` that is a referrer
488
+ * of `subjectDigest`, under the same referrers form discover reads ({@link REFERRERS_DISTRIBUTION_SPEC}). Returns the
489
+ * digest of the referrer manifest oras reports. The only write this client does; the caller states every byte.
490
+ *
491
+ * @param {string} repository
492
+ * @param {string} subjectDigest
493
+ * @param {{ artifactType: string, files: {path: string, mediaType: string}[], annotations?: Record<string,string>, cwd: string }} attachment
494
+ */
495
+ async attach(repository, subjectDigest, { artifactType, files, annotations = {}, cwd }) {
496
+ if (!SHA256_DIGEST_PATTERN.test(subjectDigest)) {
497
+ throw new Error(`"${subjectDigest}" is not a canonical lowercase sha256 digest`);
498
+ }
499
+ if (typeof artifactType !== 'string' || artifactType.length === 0) {
500
+ throw new Error('an attach requires a non-empty artifact type');
501
+ }
502
+ if (!Array.isArray(files) || files.length === 0 || typeof cwd !== 'string' || !isAbsolute(cwd)) {
503
+ throw new Error('an attach requires at least one file and an absolute working directory');
504
+ }
505
+ const ref = `${this.#ref(repository)}@${subjectDigest}`;
506
+ const flags = ['--distribution-spec', REFERRERS_DISTRIBUTION_SPEC, '--artifact-type', artifactType];
507
+ for (const [key, value] of Object.entries(annotations)) {
508
+ flags.push('--annotation', `${key}=${value}`);
509
+ }
510
+ flags.push('--format', 'json');
511
+ const out = await this.#oras(['attach'], flags, ref, {
512
+ cwd,
513
+ operands: files.map((file) => `${file.path}:${file.mediaType}`),
514
+ });
515
+ let document;
516
+ try {
517
+ document = JSON.parse(out.toString('utf8'));
518
+ } catch (error) {
519
+ throw new Error(`${ref} attach did not report JSON: ${error.message}`);
520
+ }
521
+ if (!SHA256_DIGEST_PATTERN.test(document?.digest)) {
522
+ throw new Error(`${ref} attach reported no canonical referrer digest`);
523
+ }
524
+ return document.digest;
525
+ }
526
+
527
+ /** Every tag of a repository; oras follows the registry's pagination. A failure throws, never an empty list. */
528
+ async listTags(repository) {
529
+ const ref = this.#ref(repository);
530
+ const out = await this.#oras(['repo', 'tags'], [], ref);
531
+ return out
532
+ .toString('utf8')
533
+ .split('\n')
534
+ .map((line) => line.trim())
535
+ .filter((line) => line.length > 0);
536
+ }
537
+
538
+ /**
539
+ * Referrers whose subject is the exact immutable digest, filtered by artifact
540
+ * type, read under the SAME referrers form evidence is attached with
541
+ * ({@link REFERRERS_DISTRIBUTION_SPEC}). Every returned descriptor is still re-read by digest by the caller.
542
+ */
543
+ async listReferrers(repository, subjectDigest, artifactType) {
544
+ if (!SHA256_DIGEST_PATTERN.test(subjectDigest)) {
545
+ throw new Error(`"${subjectDigest}" is not a canonical lowercase sha256 digest`);
546
+ }
547
+ if (typeof artifactType !== 'string' || artifactType.length === 0) {
548
+ throw new Error('referrers query requires a non-empty artifact type');
549
+ }
550
+ const ref = `${this.#ref(repository)}@${subjectDigest}`;
551
+ const out = await this.#oras(
552
+ ['discover'],
553
+ ['--distribution-spec', REFERRERS_DISTRIBUTION_SPEC, '--format', 'json', '--artifact-type', artifactType],
554
+ ref,
555
+ );
556
+ if (out.length > MAX_MANIFEST_BYTES) {
557
+ throw new Error(`${ref} referrers listing is larger than ${MAX_MANIFEST_BYTES} bytes`);
558
+ }
559
+ let document;
560
+ try {
561
+ document = JSON.parse(out.toString('utf8'));
562
+ } catch (error) {
563
+ throw new Error(`${ref} referrers listing is not JSON: ${error.message}`);
564
+ }
565
+ if (!Array.isArray(document?.referrers)) {
566
+ throw new Error(`${ref} did not return a referrers listing`);
567
+ }
568
+ const seen = new Set();
569
+ const manifests = [];
570
+ for (const referrer of document.referrers) {
571
+ if (!SHA256_DIGEST_PATTERN.test(referrer?.digest)) {
572
+ throw new Error(`${ref} listed a referrer without a canonical digest`);
573
+ }
574
+ if (referrer.artifactType !== artifactType) {continue;}
575
+ if (seen.has(referrer.digest)) {
576
+ throw new Error(`registry ${this.authority} repeated referrer ${referrer.digest}`);
577
+ }
578
+ seen.add(referrer.digest);
579
+ manifests.push({
580
+ mediaType: referrer.mediaType,
581
+ digest: referrer.digest,
582
+ size: referrer.size,
583
+ artifactType: referrer.artifactType,
584
+ ...(referrer.annotations ? { annotations: referrer.annotations } : {}),
585
+ });
586
+ }
587
+ return manifests;
588
+ }
589
+
590
+ /**
591
+ * Fetch a manifest in ONE call and hold it to everything a separate
592
+ * descriptor call used to: its media type is an accepted manifest type, its
593
+ * size is inside the cap, and its bytes hash to the digest the registry
594
+ * described. `expectedDigest`, when the caller asked by digest, must be that
595
+ * digest. The media type is the descriptor's, never the body's optional
596
+ * `mediaType` (Helm's has none).
597
+ */
598
+ async #manifest(ref, expectedDigest) {
599
+ const { descriptor, bytes } = await this.#fetchContent(['manifest', 'fetch'], ref, MAX_MANIFEST_BYTES, (described) => {
600
+ if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(described.mediaType)) {
601
+ throw new Error(`${ref} returned unsupported manifest media type "${described.mediaType}"`);
602
+ }
603
+ if (expectedDigest !== undefined && described.digest !== expectedDigest) {
604
+ throw new Error(`${ref} described ${described.digest}, not the requested ${expectedDigest}`);
605
+ }
606
+ });
607
+ return { ...decodeManifest(bytes, descriptor.mediaType, ref), digest: descriptor.digest, bytes };
608
+ }
609
+
610
+ /** Fetch a manifest by TAG: one call, the content verified against the digest the registry described. */
611
+ async getManifestByTag(repository, tag) {
612
+ return this.#manifest(`${this.#ref(repository)}:${tag}`);
613
+ }
614
+
615
+ /** Fetch a manifest by DIGEST: one call, the bytes must hash to exactly that digest. */
616
+ async getManifestByDigest(repository, digest) {
617
+ if (!SHA256_DIGEST_PATTERN.test(digest)) {
618
+ throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
619
+ }
620
+ return this.#manifest(`${this.#ref(repository)}@${digest}`, digest);
621
+ }
622
+
623
+ /** Fetch a blob by DIGEST in one call; its declared and actual size are bounded by `maxBytes`, its bytes must hash to that digest. */
624
+ async getBlob(repository, digest, maxBytes = MAX_CONFIG_BLOB_BYTES) {
625
+ if (!SHA256_DIGEST_PATTERN.test(digest)) {
626
+ throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
627
+ }
628
+ const ref = `${this.#ref(repository)}@${digest}`;
629
+ const { bytes } = await this.#fetchContent(['blob', 'fetch'], ref, maxBytes, (described) => {
630
+ if (described.digest !== digest) {
631
+ throw new Error(`${ref} described ${described.digest}, not the requested ${digest}`);
632
+ }
633
+ });
634
+ return bytes;
635
+ }
636
+ }
637
+
638
+ function decodeManifest(bytes, expectedMediaType, ref) {
639
+ let document;
640
+ try {
641
+ document = JSON.parse(bytes.toString('utf8'));
642
+ } catch (error) {
643
+ throw new Error(`${ref} returned a manifest that is not valid JSON: ${error.message}`);
644
+ }
645
+ if (typeof document !== 'object' || document === null || Array.isArray(document)) {
646
+ throw new Error(`${ref} returned a manifest that is not a JSON object`);
647
+ }
648
+ const mediaType = expectedMediaType;
649
+ if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(mediaType)) {
650
+ throw new Error(
651
+ `${ref} returned unsupported manifest media type "${mediaType}"`,
652
+ );
653
+ }
654
+ if (typeof document.mediaType === 'string' && document.mediaType !== mediaType) {
655
+ throw new Error(
656
+ `${ref} served media type "${mediaType}" but the manifest declares "${document.mediaType}"`,
657
+ );
658
+ }
659
+ return {
660
+ mediaType,
661
+ isIndex: OCI_INDEX_MEDIA_TYPES.includes(mediaType),
662
+ document,
663
+ };
664
+ }