@xemahq/xema 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +70 -37
  2. package/dist/assets/appliance-base/images.lock.json +126 -126
  3. package/dist/assets/appliance-lean/images.lock.json +123 -123
  4. package/dist/assets/cloud/images.lock.json +126 -126
  5. package/dist/assets/core/images.lock.json +120 -120
  6. package/dist/assets/distribution-assets.manifest.json +15 -16
  7. package/dist/assets/minimal/images.lock.json +108 -108
  8. package/dist/assets/oss/images.lock.json +126 -126
  9. package/dist/assets/starter/images.lock.json +123 -123
  10. package/dist/cli.js +2 -2
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/dev.js +1 -0
  13. package/dist/commands/dev.js.map +1 -1
  14. package/dist/commands/down.d.ts.map +1 -1
  15. package/dist/commands/down.js +7 -1
  16. package/dist/commands/down.js.map +1 -1
  17. package/dist/commands/edition.d.ts +3 -0
  18. package/dist/commands/edition.d.ts.map +1 -0
  19. package/dist/commands/edition.js +46 -0
  20. package/dist/commands/edition.js.map +1 -0
  21. package/dist/commands/init.js +2 -2
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/serve.js +1 -0
  24. package/dist/commands/serve.js.map +1 -1
  25. package/dist/commands/up.js +1 -0
  26. package/dist/commands/up.js.map +1 -1
  27. package/dist/dev/env-defaults.d.ts.map +1 -1
  28. package/dist/dev/env-defaults.js +6 -1
  29. package/dist/dev/env-defaults.js.map +1 -1
  30. package/dist/dev/infra-preflight.d.ts +3 -2
  31. package/dist/dev/infra-preflight.d.ts.map +1 -1
  32. package/dist/dev/infra-preflight.js +18 -8
  33. package/dist/dev/infra-preflight.js.map +1 -1
  34. package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
  35. package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
  36. package/dist/runtime/backing-registration.d.ts.map +1 -1
  37. package/dist/runtime/backing-registration.js +7 -2
  38. package/dist/runtime/backing-registration.js.map +1 -1
  39. package/dist/runtime/config/schema.d.ts +1 -0
  40. package/dist/runtime/config/schema.d.ts.map +1 -1
  41. package/dist/runtime/custody/local-custody.d.ts +12 -0
  42. package/dist/runtime/custody/local-custody.d.ts.map +1 -0
  43. package/dist/runtime/custody/local-custody.js +22 -0
  44. package/dist/runtime/custody/local-custody.js.map +1 -0
  45. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
  46. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
  47. package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
  48. package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
  49. package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
  50. package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
  51. package/dist/runtime/custody/openbao-custody-store.js +55 -0
  52. package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
  53. package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
  54. package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
  55. package/dist/runtime/custody/openbao-http-client.js +154 -0
  56. package/dist/runtime/custody/openbao-http-client.js.map +1 -0
  57. package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
  58. package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
  59. package/dist/runtime/custody/openbao-local-constants.js +48 -0
  60. package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
  61. package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
  62. package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
  63. package/dist/runtime/custody/openbao-local-runner.js +86 -0
  64. package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
  65. package/dist/runtime/edition/run-entry.d.ts +9 -0
  66. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  67. package/dist/runtime/edition/run-entry.js +36 -0
  68. package/dist/runtime/edition/run-entry.js.map +1 -0
  69. package/dist/runtime/edition/verb-map.d.ts +60 -0
  70. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  71. package/dist/runtime/edition/verb-map.js +146 -0
  72. package/dist/runtime/edition/verb-map.js.map +1 -0
  73. package/dist/runtime/env-seeder.d.ts +3 -0
  74. package/dist/runtime/env-seeder.d.ts.map +1 -1
  75. package/dist/runtime/env-seeder.js +20 -3
  76. package/dist/runtime/env-seeder.js.map +1 -1
  77. package/dist/runtime/launch.d.ts +3 -1
  78. package/dist/runtime/launch.d.ts.map +1 -1
  79. package/dist/runtime/launch.js +37 -8
  80. package/dist/runtime/launch.js.map +1 -1
  81. package/dist/runtime/paths.d.ts +1 -2
  82. package/dist/runtime/paths.d.ts.map +1 -1
  83. package/dist/runtime/paths.js +0 -10
  84. package/dist/runtime/paths.js.map +1 -1
  85. package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
  86. package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
  87. package/dist/runtime/runner/chart-local-provenance.js +114 -0
  88. package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
  89. package/dist/runtime/runner/chart-preflight.d.ts +8 -8
  90. package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
  91. package/dist/runtime/runner/chart-preflight.js +30 -69
  92. package/dist/runtime/runner/chart-preflight.js.map +1 -1
  93. package/dist/runtime/runner/chart-pull.d.ts +14 -0
  94. package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
  95. package/dist/runtime/runner/chart-pull.js +96 -0
  96. package/dist/runtime/runner/chart-pull.js.map +1 -0
  97. package/dist/runtime/runner/chart-resolver.d.ts +4 -12
  98. package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
  99. package/dist/runtime/runner/chart-resolver.js +16 -62
  100. package/dist/runtime/runner/chart-resolver.js.map +1 -1
  101. package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
  102. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  103. package/dist/runtime/runner/chart-set-lock.js +55 -99
  104. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  105. package/dist/runtime/runner/helm-cli.d.ts +3 -1
  106. package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
  107. package/dist/runtime/runner/helm-cli.js +9 -7
  108. package/dist/runtime/runner/helm-cli.js.map +1 -1
  109. package/dist/runtime/runner/helm-runner.d.ts +4 -4
  110. package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
  111. package/dist/runtime/runner/helm-runner.js +18 -16
  112. package/dist/runtime/runner/helm-runner.js.map +1 -1
  113. package/dist/runtime/sources/kinds/git.d.ts +1 -2
  114. package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
  115. package/dist/runtime/sources/kinds/git.js +8 -5
  116. package/dist/runtime/sources/kinds/git.js.map +1 -1
  117. package/dist/runtime/sources/manifest-walk.d.ts +3 -1
  118. package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
  119. package/dist/runtime/sources/manifest-walk.js +8 -3
  120. package/dist/runtime/sources/manifest-walk.js.map +1 -1
  121. package/dist/runtime/sources/schema.d.ts +3 -0
  122. package/dist/runtime/sources/schema.d.ts.map +1 -1
  123. package/dist/runtime/sources/schema.js +6 -1
  124. package/dist/runtime/sources/schema.js.map +1 -1
  125. package/dist/runtime/supervisor.d.ts +3 -0
  126. package/dist/runtime/supervisor.d.ts.map +1 -1
  127. package/dist/runtime/supervisor.js +2 -0
  128. package/dist/runtime/supervisor.js.map +1 -1
  129. package/edition/README.md +315 -0
  130. package/edition/assemble-producer-input.mjs +1251 -0
  131. package/edition/authorization-base.mjs +347 -0
  132. package/edition/biome-content.mjs +268 -0
  133. package/edition/build-biome-index.mjs +175 -0
  134. package/edition/build-platform-service-index.mjs +288 -0
  135. package/edition/build-runtime-image-source-index.mjs +821 -0
  136. package/edition/built-verifier.mjs +55 -0
  137. package/edition/catalogue-edition.mjs +49 -0
  138. package/edition/catalogue-fetch.mjs +64 -0
  139. package/edition/catalogue.mjs +135 -0
  140. package/edition/chart-set-source-commit.mjs +92 -0
  141. package/edition/check-committed-image-evidence.mjs +383 -0
  142. package/edition/checkout-indexed-source-repositories.mjs +105 -0
  143. package/edition/comment-blanker.mjs +75 -0
  144. package/edition/connector-source-authority.mjs +60 -0
  145. package/edition/customer-definition.mjs +183 -0
  146. package/edition/customer-flags.mjs +7 -0
  147. package/edition/develop-customer-edition.mjs +99 -0
  148. package/edition/develop-edition-resolve.mjs +65 -0
  149. package/edition/develop-edition.mjs +274 -0
  150. package/edition/develop-freeze.mjs +173 -0
  151. package/edition/develop-image-coordinate.mjs +54 -0
  152. package/edition/develop-seal.mjs +77 -0
  153. package/edition/develop-snapshot-inputs.mjs +104 -0
  154. package/edition/dist-check.mjs +986 -0
  155. package/edition/distribution-evidence-set.mjs +987 -0
  156. package/edition/distribution-paths.mjs +191 -0
  157. package/edition/edition-artifact.mjs +270 -0
  158. package/edition/edition-biome-manifests.mjs +85 -0
  159. package/edition/edition-channel.mjs +6 -0
  160. package/edition/edition-chart-set.mjs +93 -0
  161. package/edition/edition-config.mjs +207 -0
  162. package/edition/edition-constants.mjs +57 -0
  163. package/edition/edition-contract.json +23 -0
  164. package/edition/edition-list.mjs +35 -0
  165. package/edition/edition-locks.mjs +69 -0
  166. package/edition/edition-protocol.mjs +228 -0
  167. package/edition/edition-workspace.mjs +176 -0
  168. package/edition/evidence-checkout.mjs +418 -0
  169. package/edition/evidence-grammars.mjs +16 -0
  170. package/edition/evidence-trust-pin.mjs +304 -0
  171. package/edition/evidence-trust-policy.mjs +256 -0
  172. package/edition/external-source-walk.mjs +463 -0
  173. package/edition/first-party-source-ids.mjs +59 -0
  174. package/edition/fleet-coordinate-finality.mjs +350 -0
  175. package/edition/git-source-auth.mjs +52 -0
  176. package/edition/image-evidence-replay-state.mjs +242 -0
  177. package/edition/image-evidence.mjs +1225 -0
  178. package/edition/kernel-contract-authority.mjs +443 -0
  179. package/edition/lib.mjs +3018 -0
  180. package/edition/no-follow-file.mjs +94 -0
  181. package/edition/oci-evidence-verbs.mjs +392 -0
  182. package/edition/oci-image-source-evidence.mjs +239 -0
  183. package/edition/oras.mjs +648 -0
  184. package/edition/pin-source.mjs +227 -0
  185. package/edition/plan-image-evidence-refresh.mjs +408 -0
  186. package/edition/platform-lock-readability.mjs +892 -0
  187. package/edition/platform-service-class.mjs +18 -0
  188. package/edition/platform-service-source-authority.mjs +105 -0
  189. package/edition/positive-closure.mjs +162 -0
  190. package/edition/produce-distribution-completeness-evidence.mjs +311 -0
  191. package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
  192. package/edition/produce-image-source-evidence.mjs +765 -0
  193. package/edition/producer-evidence-bundle.mjs +157 -0
  194. package/edition/registry-credentials.mjs +193 -0
  195. package/edition/release-catalogue.mjs +39 -0
  196. package/edition/release-channel-guard.mjs +99 -0
  197. package/edition/release-edition-artifact.mjs +158 -0
  198. package/edition/remedy-command.mjs +89 -0
  199. package/edition/repin-evidence-trust.mjs +101 -0
  200. package/edition/required-resource-provider.mjs +58 -0
  201. package/edition/resolve-chart-set.mjs +415 -0
  202. package/edition/resolve-customer-edition.mjs +288 -0
  203. package/edition/resolve-distribution.mjs +2318 -0
  204. package/edition/resolve-edition.mjs +186 -0
  205. package/edition/resolve-images.mjs +1606 -0
  206. package/edition/seal-image-digests.mjs +855 -0
  207. package/edition/sealed-evidence-accept.mjs +82 -0
  208. package/edition/source-authority.mjs +725 -0
  209. package/edition/source-closure.mjs +116 -0
  210. package/edition/strict-json.mjs +121 -0
  211. package/edition/string-order.mjs +5 -0
  212. package/edition/trust-policy-arming.mjs +120 -0
  213. package/edition/trusted-executable.mjs +166 -0
  214. package/edition/verifier-binding.mjs +45 -0
  215. package/edition/verifier-files.mjs +8 -0
  216. package/edition/verifier-kernel-schemas.mjs +22 -0
  217. package/edition/verifier-provenance.mjs +64 -0
  218. package/edition/verify-image-evidence.mjs +430 -0
  219. package/edition/verify-image-platforms.mjs +495 -0
  220. package/edition/why.mjs +56 -0
  221. package/edition/workspace-sections.mjs +195 -0
  222. package/examples/06-biome-sources.yaml +1 -0
  223. package/package.json +18 -7
  224. package/dist/commands/dist.d.ts +0 -16
  225. package/dist/commands/dist.d.ts.map +0 -1
  226. package/dist/commands/dist.js +0 -182
  227. package/dist/commands/dist.js.map +0 -1
  228. package/dist/runtime/distribution/available-biome.d.ts +0 -10
  229. package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
  230. package/dist/runtime/distribution/available-biome.js +0 -154
  231. package/dist/runtime/distribution/available-biome.js.map +0 -1
  232. package/dist/runtime/distribution/compose.d.ts +0 -23
  233. package/dist/runtime/distribution/compose.d.ts.map +0 -1
  234. package/dist/runtime/distribution/compose.js +0 -48
  235. package/dist/runtime/distribution/compose.js.map +0 -1
  236. package/dist/runtime/runner/chart-trust.d.ts +0 -57
  237. package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
  238. package/dist/runtime/runner/chart-trust.js +0 -270
  239. package/dist/runtime/runner/chart-trust.js.map +0 -1
@@ -0,0 +1,648 @@
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 a fresh 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 digest-addressed read whose bytes do not hash to the requested digest
23
+ // throws; a tag read is resolved to a descriptor first, then read by digest,
24
+ // so its bytes are always hash-checked;
25
+ // * descriptor sizes are checked BEFORE the fetch, actual lengths after it;
26
+ // * a registry status is read only from oras's own error forms; any other
27
+ // failure carries no status and is thrown as undecided;
28
+ // * `--plain-http` and `--insecure` are never passed.
29
+
30
+ import { execFileSync } from 'node:child_process';
31
+ import { createHash } from 'node:crypto';
32
+ import {
33
+ accessSync,
34
+ constants,
35
+ mkdtempSync,
36
+ realpathSync,
37
+ rmSync,
38
+ statSync,
39
+ writeFileSync,
40
+ } from 'node:fs';
41
+ import { tmpdir } from 'node:os';
42
+ import { delimiter, isAbsolute, join } from 'node:path';
43
+
44
+ import { editionWorkspaceConfig } from './edition-config.mjs';
45
+ import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
46
+ import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
47
+ import { readEvidenceTrustPin } from './evidence-trust-pin.mjs';
48
+ import {
49
+ OCI_INDEX_MEDIA_TYPES,
50
+ REGISTRY_CREDENTIALS_FILE,
51
+ } from './registry-credentials.mjs';
52
+ import {
53
+ assertPreparedExecutable,
54
+ exactExecutableOutput,
55
+ prepareDigestPinnedExecutable,
56
+ } from './trusted-executable.mjs';
57
+
58
+ /** The oras version whose arguments, JSON and error forms this module is written against. */
59
+ export const ORAS_PINNED_VERSION = '1.3.4';
60
+
61
+ /**
62
+ * The referrers form every artifact is ATTACHED and DISCOVERED with. One
63
+ * constant for both directions: an artifact attached under the referrers tag
64
+ * schema is only found by a discover that reads the same schema.
65
+ */
66
+ export const REFERRERS_DISTRIBUTION_SPEC = 'v1.1-referrers-tag';
67
+
68
+ /** The refusal of a Bearer token realm on another origin than the registry's. */
69
+ export const REGISTRY_TOKEN_REALM_CROSS_HOST = 'REGISTRY_TOKEN_REALM_CROSS_HOST';
70
+
71
+ export class RegistryTokenRealmError extends Error {
72
+ /** @param {string} authority @param {string} realmOrigin */
73
+ constructor(authority, realmOrigin) {
74
+ super(
75
+ `registry ${authority} advertised token realm "${realmOrigin}", which is not its own origin; ` +
76
+ 'the pull credential is never sent to a third-party host',
77
+ );
78
+ this.name = 'RegistryTokenRealmError';
79
+ this.code = REGISTRY_TOKEN_REALM_CROSS_HOST;
80
+ }
81
+ }
82
+
83
+ const ORAS_VERSION_PATTERN = /^Version:\s+1\.3\.4(\+\S+)?$/m;
84
+ const ORAS_BIN_ENV = 'XEMA_ORAS_BIN';
85
+ const ORAS_RUN_TIMEOUT_MS = 120_000;
86
+ const ORAS_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
87
+
88
+ const ACCEPTED_MANIFEST_MEDIA_TYPES = Object.freeze([
89
+ ...OCI_INDEX_MEDIA_TYPES,
90
+ 'application/vnd.oci.image.manifest.v1+json',
91
+ 'application/vnd.docker.distribution.manifest.v2+json',
92
+ ]);
93
+
94
+ const REGISTRY_REQUEST_TIMEOUT_MS = 30_000;
95
+ const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
96
+ const MAX_DESCRIPTOR_BYTES = 64 * 1024;
97
+ const MAX_CONFIG_BLOB_BYTES = 4 * 1024 * 1024;
98
+ const MAX_ERROR_DETAIL_CHARS = 512;
99
+
100
+ function sha256DigestOf(bytes) {
101
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
102
+ }
103
+
104
+ function isExecutableFile(path) {
105
+ try {
106
+ if (!statSync(path).isFile()) {return false;}
107
+ accessSync(path, constants.X_OK);
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ function firstOnPath(env) {
115
+ for (const directory of (env.PATH ?? '').split(delimiter)) {
116
+ if (!directory) {continue;}
117
+ const candidate = join(directory, 'oras');
118
+ if (isExecutableFile(candidate)) {return candidate;}
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * The oras binary as a real file: `XEMA_ORAS_BIN` (an absolute path) wins,
125
+ * otherwise the first `oras` on PATH. Symbolic links are resolved here, so the
126
+ * path returned is the file whose bytes are digested. Nothing is executed.
127
+ */
128
+ export function locateOras({ env = process.env } = {}) {
129
+ let bin;
130
+ if (env[ORAS_BIN_ENV] !== undefined && env[ORAS_BIN_ENV] !== '') {
131
+ bin = env[ORAS_BIN_ENV];
132
+ if (!isAbsolute(bin)) {
133
+ throw new Error(`${ORAS_BIN_ENV} must be an absolute path to the oras binary, got "${bin}"`);
134
+ }
135
+ } else {
136
+ bin = firstOnPath(env);
137
+ if (!bin) {
138
+ throw new Error(
139
+ `no oras binary found on PATH; install oras ${ORAS_PINNED_VERSION} or set ${ORAS_BIN_ENV} to its absolute path`,
140
+ );
141
+ }
142
+ }
143
+ try {
144
+ return realpathSync(bin);
145
+ } catch (error) {
146
+ throw new Error(`the oras binary ${bin} is not readable: ${error.message}`);
147
+ }
148
+ }
149
+
150
+ /** The environment oras runs under: nothing ambient, and a HOME that holds nothing. */
151
+ function orasEnvironment(home) {
152
+ return { HOME: home, LANG: 'C', LC_ALL: 'C', PATH: '/usr/bin:/bin', TZ: 'UTC' };
153
+ }
154
+
155
+ function assertOrasVersion(bin, home) {
156
+ let output;
157
+ try {
158
+ output = exactExecutableOutput(bin, ['version'], { timeout: 30_000, env: orasEnvironment(home) });
159
+ } catch (error) {
160
+ throw new Error(
161
+ `could not run "${bin} version": ${error instanceof Error ? error.message : String(error)}`,
162
+ );
163
+ }
164
+ if (!ORAS_VERSION_PATTERN.test(output)) {
165
+ const found = /^Version:\s+(\S+)/m.exec(output)?.[1] ?? '(unreadable)';
166
+ throw new Error(
167
+ `${bin} is oras ${found}, but this tooling is pinned to oras ${ORAS_PINNED_VERSION}`,
168
+ );
169
+ }
170
+ return /^Version:\s+(\S+)/m.exec(output)[1];
171
+ }
172
+
173
+ /**
174
+ * The oras digests the configured data root trusts: `trustedOrasBinaryDigests`
175
+ * of its evidence-trust pin. `null` when the data root declares no evidence
176
+ * trust at all (`evidenceTrustPolicy: {"required": false}` with no policy):
177
+ * such a data root has no reviewed pin to hold a binary to.
178
+ */
179
+ export function configuredOrasTrust() {
180
+ const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
181
+ if (declaration.policy === null) {return null;}
182
+ return readEvidenceTrustPin({ dataRoot: configuredDataRoot(), declaration })
183
+ .trustedOrasBinaryDigests;
184
+ }
185
+
186
+ /** Each notice sink is told once: the default `console.warn` once per process, a test's own sink once per test. */
187
+ const noticesGiven = new WeakSet();
188
+
189
+ /**
190
+ * Prepare the oras binary for use. With `trustedDigests` (an array) the binary
191
+ * must hash to one of them and a private copy of exactly those bytes is what
192
+ * runs; an empty list trusts no binary. With `trustedDigests: null` (a data
193
+ * root that declares no evidence trust) the binary is held to its version only,
194
+ * and the run says so once.
195
+ *
196
+ * @returns {{version: string, pinned: boolean, path: () => string, dispose: () => void}}
197
+ */
198
+ export function prepareTrustedOras({ env = process.env, trustedDigests, notice = console.warn } = {}) {
199
+ if (trustedDigests !== null && !Array.isArray(trustedDigests)) {
200
+ throw new Error('the trusted oras digests are a list, or null for a data root that declares no evidence trust');
201
+ }
202
+ const located = locateOras({ env });
203
+ const home = mkdtempSync(join(tmpdir(), 'xema-oras-home-'));
204
+ try {
205
+ if (trustedDigests === null) {
206
+ const version = assertOrasVersion(located, home);
207
+ if (!noticesGiven.has(notice)) {
208
+ noticesGiven.add(notice);
209
+ notice(
210
+ `[oras] ${located} is held to its version (${version}) only: this data root declares no evidence-trust pin, ` +
211
+ 'so there are no trustedOrasBinaryDigests to hold it to.',
212
+ );
213
+ }
214
+ return {
215
+ version,
216
+ pinned: false,
217
+ home,
218
+ path: () => located,
219
+ dispose: () => rmSync(home, { recursive: true, force: true }),
220
+ };
221
+ }
222
+ let version;
223
+ const handle = prepareDigestPinnedExecutable({
224
+ executablePath: located,
225
+ trustedDigests,
226
+ label: 'oras',
227
+ verifyVersion: (privateBinary) => {
228
+ version = assertOrasVersion(privateBinary, home);
229
+ },
230
+ });
231
+ return {
232
+ version,
233
+ pinned: true,
234
+ home,
235
+ path: () => assertPreparedExecutable(handle, 'oras'),
236
+ dispose: () => {
237
+ handle.dispose();
238
+ rmSync(home, { recursive: true, force: true });
239
+ },
240
+ };
241
+ } catch (error) {
242
+ rmSync(home, { recursive: true, force: true });
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ /** The default runner: the trusted oras of the configured data root, under the scrubbed environment. */
248
+ function defaultRunner(trust = configuredOrasTrust) {
249
+ let prepared;
250
+ return (args, { stdin } = {}) => {
251
+ if (prepared === undefined) {
252
+ prepared = prepareTrustedOras({ trustedDigests: trust() });
253
+ process.once('exit', prepared.dispose);
254
+ }
255
+ try {
256
+ return execFileSync(prepared.path(), args, {
257
+ input: stdin,
258
+ env: orasEnvironment(prepared.home),
259
+ timeout: ORAS_RUN_TIMEOUT_MS,
260
+ maxBuffer: ORAS_MAX_BUFFER_BYTES,
261
+ stdio: ['pipe', 'pipe', 'pipe'],
262
+ });
263
+ } catch (error) {
264
+ const stderr = error?.stderr ? error.stderr.toString('utf8') : '';
265
+ const wrapped = new Error(stderr.trim() || (error instanceof Error ? error.message : String(error)));
266
+ // Only what oras itself wrote is offered as a registry answer; a spawn
267
+ // failure, a timeout or a signal carries no stderr and so no status.
268
+ wrapped.stderr = stderr;
269
+ throw wrapped;
270
+ }
271
+ };
272
+ }
273
+
274
+ function parseWwwAuthenticate(header) {
275
+ if (typeof header !== 'string' || !/^Bearer\s/i.test(header)) {return null;}
276
+ const parameters = new Map();
277
+ const pattern = /([a-zA-Z_]+)="((?:[^"\\]|\\.)*)"/g;
278
+ let match = pattern.exec(header);
279
+ while (match) {
280
+ parameters.set(match[1], match[2].replace(/\\(.)/g, '$1'));
281
+ match = pattern.exec(header);
282
+ }
283
+ const realm = parameters.get('realm');
284
+ if (!realm) {return null;}
285
+ return { realm };
286
+ }
287
+
288
+ const ORAS_REGISTRY_ERROR_PREFIX = 'Error response from registry: ';
289
+ const ORAS_ERROR_PREFIX = 'Error: ';
290
+
291
+ /**
292
+ * The HTTP status a registry answered with; null when the failure is not a
293
+ * registry answer.
294
+ *
295
+ * oras 1.3.4 has no structured error output and exits 1 for every failure, so
296
+ * the status is read from its stderr, and ONLY from the forms oras itself
297
+ * prints (captured against a registry; see oras.test.mjs). The FIRST line of
298
+ * stderr must be oras's own error line, and within it:
299
+ * * `Error response from registry: … response status code <NNN>: …` carries
300
+ * the registry's status itself;
301
+ * * `Error response from registry: … <ref>: not found` and
302
+ * `Error: … <ref>: not found`, ending the line with the EXACT reference that
303
+ * was asked for, are oras's resolution of a 404;
304
+ * * `Error response from registry: name unknown: …` is the registry's error
305
+ * code for a missing repository, in the position oras prints it.
306
+ * Everything else is null: text from a wrapper, a shell (`oras: not found`,
307
+ * `Permission denied`), a proxy or a transport failure is not a registry
308
+ * answer, whatever words it contains, and neither is a status-like word inside
309
+ * a reference or a quoted string. A null status is thrown by the caller as an
310
+ * UNDECIDED failure; it is never read as "absent".
311
+ */
312
+ export function statusOfOrasFailure(text, { ref = '' } = {}) {
313
+ const [line] = String(text).split('\n');
314
+ const fromRegistry = line.startsWith(ORAS_REGISTRY_ERROR_PREFIX);
315
+ if (!fromRegistry && !line.startsWith(ORAS_ERROR_PREFIX)) {return null;}
316
+ if (fromRegistry) {
317
+ // The status oras reports sits after the quoted request URL; a reference
318
+ // that spells `response status code 404` is inside the quotes.
319
+ const explicit = /\bresponse status code (\d{3})\b/.exec(line.replace(/"(?:[^"\\]|\\.)*"/g, '""'));
320
+ if (explicit) {return Number(explicit[1]);}
321
+ if (/^name unknown: /.test(line.slice(ORAS_REGISTRY_ERROR_PREFIX.length))) {return 404;}
322
+ }
323
+ if (ref && line.endsWith(` ${ref}: not found`)) {return 404;}
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * A read-only OCI registry client bound to ONE registry authority and ONE
329
+ * resolved credential, doing every operation with the pinned oras.
330
+ */
331
+ export class OrasRegistry {
332
+ #credential;
333
+ #run;
334
+ #fetchImpl;
335
+ #username = null;
336
+ #token = null;
337
+ #realmCheck = null;
338
+
339
+ /**
340
+ * `run` replaces the oras invocation (tests). Without it the trusted oras of
341
+ * the configured data root runs; `trust` states the trusted digests instead
342
+ * of reading them from the data root's pin.
343
+ */
344
+ constructor({ credential, run, trust, fetchImpl = globalThis.fetch }) {
345
+ if (typeof credential?.authority !== 'string') {
346
+ throw new Error('an oras registry requires a resolved credential');
347
+ }
348
+ if (typeof fetchImpl !== 'function') {
349
+ throw new Error('an oras registry requires a fetch implementation for the token-realm check');
350
+ }
351
+ this.#credential = credential;
352
+ this.#run = run ?? defaultRunner(trust);
353
+ this.#fetchImpl = fetchImpl;
354
+ if (credential.access === 'authenticated') {
355
+ const decoded = Buffer.from(credential.basicAuth ?? '', 'base64').toString('utf8');
356
+ const colon = decoded.indexOf(':');
357
+ if (colon <= 0 || colon === decoded.length - 1) {
358
+ throw new Error(`registry ${credential.authority} is authenticated but its credential is incomplete`);
359
+ }
360
+ this.#username = decoded.slice(0, colon);
361
+ this.#token = decoded.slice(colon + 1);
362
+ }
363
+ }
364
+
365
+ get authority() {
366
+ return this.#credential.authority;
367
+ }
368
+
369
+ /**
370
+ * The Bearer token realm must be the registry's OWN origin. Checked once,
371
+ * with an unauthenticated probe, BEFORE oras is handed the credential: a
372
+ * registry that points its realm at another host would otherwise receive the
373
+ * pull token (a credential exfiltration primitive).
374
+ */
375
+ #ensureRealmIsSameOrigin() {
376
+ if (this.#credential.access !== 'authenticated') {return Promise.resolve();}
377
+ this.#realmCheck ??= this.#probeRealm();
378
+ return this.#realmCheck;
379
+ }
380
+
381
+ async #probeRealm() {
382
+ const origin = `https://${this.authority}`;
383
+ let response;
384
+ try {
385
+ response = await this.#fetchImpl(`${origin}/v2/`, {
386
+ method: 'GET',
387
+ redirect: 'manual',
388
+ headers: { 'user-agent': 'xema-edition-platform-verifier/1' },
389
+ signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
390
+ });
391
+ } catch (error) {
392
+ throw new Error(
393
+ `registry request to ${origin}/v2/ failed: ${error instanceof Error ? error.message : String(error)}`,
394
+ );
395
+ }
396
+ if (response.status === 200) {return;}
397
+ if (response.status !== 401) {
398
+ throw new Error(`registry ${this.authority} answered its /v2/ probe with ${response.status}`);
399
+ }
400
+ const challenge = parseWwwAuthenticate(response.headers.get('www-authenticate'));
401
+ if (!challenge) {return;}
402
+ let url;
403
+ try {
404
+ url = new URL(challenge.realm);
405
+ } catch {
406
+ throw new Error(
407
+ `registry ${this.authority} advertised a token realm that is not an absolute URL`,
408
+ );
409
+ }
410
+ if (url.origin !== origin) {
411
+ throw new RegistryTokenRealmError(this.authority, url.origin);
412
+ }
413
+ }
414
+
415
+ #scrub(text) {
416
+ return this.#token ? text.split(this.#token).join('***') : text;
417
+ }
418
+
419
+ /** One oras invocation with an empty registry config; returns stdout bytes. */
420
+ async #oras(subcommand, flags, ref) {
421
+ await this.#ensureRealmIsSameOrigin();
422
+ const configDirectory = mkdtempSync(join(tmpdir(), 'xema-oras-'));
423
+ const configPath = join(configDirectory, 'registry-config.json');
424
+ try {
425
+ writeFileSync(configPath, '');
426
+ const args = [...subcommand, '--registry-config', configPath];
427
+ let stdin;
428
+ if (this.#credential.access === 'authenticated') {
429
+ args.push('--username', this.#username, '--password-stdin');
430
+ stdin = this.#token;
431
+ }
432
+ args.push(...flags, ref);
433
+ try {
434
+ const out = await this.#run(args, { stdin });
435
+ return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
436
+ } catch (error) {
437
+ throw this.#failure(ref, error);
438
+ }
439
+ } finally {
440
+ rmSync(configDirectory, { recursive: true, force: true });
441
+ }
442
+ }
443
+
444
+ #failure(ref, error) {
445
+ // The status comes from oras's stderr ALONE. A failure with no stderr (the
446
+ // binary did not start, timed out, was killed) is local, and its message is
447
+ // shown but never parsed for a status.
448
+ const stderr = this.#scrub(typeof error?.stderr === 'string' ? error.stderr.trim() : '');
449
+ const status = stderr ? statusOfOrasFailure(stderr, { ref }) : null;
450
+ const raw = this.#scrub(error instanceof Error ? error.message : String(error));
451
+ const detail = (stderr || raw).slice(0, MAX_ERROR_DETAIL_CHARS).trim();
452
+ if (status === null) {
453
+ return new Error(`registry request to ${ref} failed: ${detail}`);
454
+ }
455
+ const hint =
456
+ (status === 401 || status === 403) && this.#credential.access === 'anonymous'
457
+ ? ` — registry "${this.authority}" is declared "anonymous" in ${REGISTRY_CREDENTIALS_FILE}; if its packages are private, declare it "authenticated"`
458
+ : '';
459
+ const failure = new Error(
460
+ `${ref} returned HTTP ${status}${hint}${detail ? `: ${detail}` : ''}`,
461
+ );
462
+ failure.status = status;
463
+ return failure;
464
+ }
465
+
466
+ #ref(repository) {
467
+ return `${this.authority}/${repository}`;
468
+ }
469
+
470
+ #parseDescriptor(bytes, ref) {
471
+ if (bytes.length > MAX_DESCRIPTOR_BYTES) {
472
+ throw new Error(`${ref} descriptor is larger than ${MAX_DESCRIPTOR_BYTES} bytes`);
473
+ }
474
+ let descriptor;
475
+ try {
476
+ descriptor = JSON.parse(bytes.toString('utf8'));
477
+ } catch (error) {
478
+ throw new Error(`${ref} descriptor is not JSON: ${error.message}`);
479
+ }
480
+ if (
481
+ typeof descriptor !== 'object' ||
482
+ descriptor === null ||
483
+ typeof descriptor.mediaType !== 'string' ||
484
+ !SHA256_DIGEST_PATTERN.test(descriptor.digest) ||
485
+ !Number.isSafeInteger(descriptor.size) ||
486
+ descriptor.size < 0
487
+ ) {
488
+ throw new Error(`${ref} did not return a usable descriptor`);
489
+ }
490
+ return descriptor;
491
+ }
492
+
493
+ /** Every tag of a repository; oras follows the registry's pagination. A failure throws, never an empty list. */
494
+ async listTags(repository) {
495
+ const ref = this.#ref(repository);
496
+ const out = await this.#oras(['repo', 'tags'], [], ref);
497
+ return out
498
+ .toString('utf8')
499
+ .split('\n')
500
+ .map((line) => line.trim())
501
+ .filter((line) => line.length > 0);
502
+ }
503
+
504
+ /**
505
+ * Referrers whose subject is the exact immutable digest, filtered by artifact
506
+ * type, read under the SAME referrers form evidence is attached with
507
+ * ({@link REFERRERS_DISTRIBUTION_SPEC}). Every returned descriptor is still re-read by digest by the caller.
508
+ */
509
+ async listReferrers(repository, subjectDigest, artifactType) {
510
+ if (!SHA256_DIGEST_PATTERN.test(subjectDigest)) {
511
+ throw new Error(`"${subjectDigest}" is not a canonical lowercase sha256 digest`);
512
+ }
513
+ if (typeof artifactType !== 'string' || artifactType.length === 0) {
514
+ throw new Error('referrers query requires a non-empty artifact type');
515
+ }
516
+ const ref = `${this.#ref(repository)}@${subjectDigest}`;
517
+ const out = await this.#oras(
518
+ ['discover'],
519
+ ['--distribution-spec', REFERRERS_DISTRIBUTION_SPEC, '--format', 'json', '--artifact-type', artifactType],
520
+ ref,
521
+ );
522
+ if (out.length > MAX_MANIFEST_BYTES) {
523
+ throw new Error(`${ref} referrers listing is larger than ${MAX_MANIFEST_BYTES} bytes`);
524
+ }
525
+ let document;
526
+ try {
527
+ document = JSON.parse(out.toString('utf8'));
528
+ } catch (error) {
529
+ throw new Error(`${ref} referrers listing is not JSON: ${error.message}`);
530
+ }
531
+ if (!Array.isArray(document?.referrers)) {
532
+ throw new Error(`${ref} did not return a referrers listing`);
533
+ }
534
+ const seen = new Set();
535
+ const manifests = [];
536
+ for (const referrer of document.referrers) {
537
+ if (!SHA256_DIGEST_PATTERN.test(referrer?.digest)) {
538
+ throw new Error(`${ref} listed a referrer without a canonical digest`);
539
+ }
540
+ if (referrer.artifactType !== artifactType) {continue;}
541
+ if (seen.has(referrer.digest)) {
542
+ throw new Error(`registry ${this.authority} repeated referrer ${referrer.digest}`);
543
+ }
544
+ seen.add(referrer.digest);
545
+ manifests.push({
546
+ mediaType: referrer.mediaType,
547
+ digest: referrer.digest,
548
+ size: referrer.size,
549
+ artifactType: referrer.artifactType,
550
+ ...(referrer.annotations ? { annotations: referrer.annotations } : {}),
551
+ });
552
+ }
553
+ return manifests;
554
+ }
555
+
556
+ /**
557
+ * Fetch a manifest by TAG: resolve the descriptor, then read by digest, so the
558
+ * bytes are always hash-checked against what the registry said the tag is.
559
+ */
560
+ async getManifestByTag(repository, tag) {
561
+ const ref = `${this.#ref(repository)}:${tag}`;
562
+ const out = await this.#oras(['manifest', 'fetch'], ['--descriptor'], ref);
563
+ const descriptor = this.#parseDescriptor(out, ref);
564
+ if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(descriptor.mediaType)) {
565
+ throw new Error(
566
+ `${ref} returned unsupported manifest media type "${descriptor.mediaType}"`,
567
+ );
568
+ }
569
+ if (descriptor.size > MAX_MANIFEST_BYTES) {
570
+ throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${MAX_MANIFEST_BYTES}`);
571
+ }
572
+ return this.#manifestByDigest(repository, descriptor.digest, descriptor.mediaType);
573
+ }
574
+
575
+ /** Fetch a manifest by DIGEST; the bytes must hash to exactly that digest. */
576
+ async getManifestByDigest(repository, digest) {
577
+ return this.#manifestByDigest(repository, digest, null);
578
+ }
579
+
580
+ async #manifestByDigest(repository, digest, expectedMediaType) {
581
+ if (!SHA256_DIGEST_PATTERN.test(digest)) {
582
+ throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
583
+ }
584
+ const ref = `${this.#ref(repository)}@${digest}`;
585
+ const bytes = await this.#oras(['manifest', 'fetch'], [], ref);
586
+ if (bytes.length > MAX_MANIFEST_BYTES) {
587
+ throw new Error(`${ref} returned more than ${MAX_MANIFEST_BYTES} bytes`);
588
+ }
589
+ const actual = sha256DigestOf(bytes);
590
+ if (actual !== digest) {
591
+ throw new Error(`${ref} returned content addressed ${actual}, not the requested ${digest}`);
592
+ }
593
+ return { ...decodeManifest(bytes, expectedMediaType, ref), digest, bytes };
594
+ }
595
+
596
+ /** Fetch a blob by DIGEST; the descriptor size is checked before the fetch, the bytes after. */
597
+ async getBlob(repository, digest, maxBytes = MAX_CONFIG_BLOB_BYTES) {
598
+ if (!SHA256_DIGEST_PATTERN.test(digest)) {
599
+ throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
600
+ }
601
+ const ref = `${this.#ref(repository)}@${digest}`;
602
+ const described = await this.#oras(['blob', 'fetch'], ['--descriptor'], ref);
603
+ const descriptor = this.#parseDescriptor(described, ref);
604
+ if (descriptor.digest !== digest) {
605
+ throw new Error(`${ref} described ${descriptor.digest}, not the requested ${digest}`);
606
+ }
607
+ if (descriptor.size > maxBytes) {
608
+ throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${maxBytes}`);
609
+ }
610
+ const bytes = await this.#oras(['blob', 'fetch'], ['--output', '-'], ref);
611
+ if (bytes.length > maxBytes) {
612
+ throw new Error(`${ref} returned more than ${maxBytes} bytes`);
613
+ }
614
+ const actual = sha256DigestOf(bytes);
615
+ if (actual !== digest) {
616
+ throw new Error(`${ref} returned content addressed ${actual}, not the requested ${digest}`);
617
+ }
618
+ return bytes;
619
+ }
620
+ }
621
+
622
+ function decodeManifest(bytes, expectedMediaType, ref) {
623
+ let document;
624
+ try {
625
+ document = JSON.parse(bytes.toString('utf8'));
626
+ } catch (error) {
627
+ throw new Error(`${ref} returned a manifest that is not valid JSON: ${error.message}`);
628
+ }
629
+ if (typeof document !== 'object' || document === null || Array.isArray(document)) {
630
+ throw new Error(`${ref} returned a manifest that is not a JSON object`);
631
+ }
632
+ const mediaType = expectedMediaType ?? document.mediaType;
633
+ if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(mediaType)) {
634
+ throw new Error(
635
+ `${ref} returned unsupported manifest media type "${mediaType || '(none)'}"`,
636
+ );
637
+ }
638
+ if (typeof document.mediaType === 'string' && document.mediaType !== mediaType) {
639
+ throw new Error(
640
+ `${ref} served media type "${mediaType}" but the manifest declares "${document.mediaType}"`,
641
+ );
642
+ }
643
+ return {
644
+ mediaType,
645
+ isIndex: OCI_INDEX_MEDIA_TYPES.includes(mediaType),
646
+ document,
647
+ };
648
+ }