@xemahq/xema 0.26.0 → 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.
- package/README.md +70 -37
- package/dist/assets/distribution-assets.manifest.json +1 -2
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.js +1 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +7 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/edition.d.ts +3 -0
- package/dist/commands/edition.d.ts.map +1 -0
- package/dist/commands/edition.js +46 -0
- package/dist/commands/edition.js.map +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/serve.js +1 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/up.js +1 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +5 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/dev/infra-preflight.d.ts +3 -2
- package/dist/dev/infra-preflight.d.ts.map +1 -1
- package/dist/dev/infra-preflight.js +18 -8
- package/dist/dev/infra-preflight.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
- package/dist/runtime/config/schema.d.ts +1 -0
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/custody/local-custody.d.ts +12 -0
- package/dist/runtime/custody/local-custody.d.ts.map +1 -0
- package/dist/runtime/custody/local-custody.js +22 -0
- package/dist/runtime/custody/local-custody.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.js +55 -0
- package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
- package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
- package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-http-client.js +154 -0
- package/dist/runtime/custody/openbao-http-client.js.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.js +48 -0
- package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.js +86 -0
- package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
- package/dist/runtime/edition/run-entry.d.ts +9 -0
- package/dist/runtime/edition/run-entry.d.ts.map +1 -0
- package/dist/runtime/edition/run-entry.js +36 -0
- package/dist/runtime/edition/run-entry.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +60 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -0
- package/dist/runtime/edition/verb-map.js +146 -0
- package/dist/runtime/edition/verb-map.js.map +1 -0
- package/dist/runtime/env-seeder.d.ts +2 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +18 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +2 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +31 -7
- package/dist/runtime/launch.js.map +1 -1
- package/dist/runtime/paths.d.ts +1 -2
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +0 -10
- package/dist/runtime/paths.js.map +1 -1
- package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
- package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
- package/dist/runtime/runner/chart-local-provenance.js +114 -0
- package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
- package/dist/runtime/runner/chart-preflight.d.ts +8 -8
- package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
- package/dist/runtime/runner/chart-preflight.js +30 -69
- package/dist/runtime/runner/chart-preflight.js.map +1 -1
- package/dist/runtime/runner/chart-pull.d.ts +14 -0
- package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
- package/dist/runtime/runner/chart-pull.js +96 -0
- package/dist/runtime/runner/chart-pull.js.map +1 -0
- package/dist/runtime/runner/chart-resolver.d.ts +4 -12
- package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
- package/dist/runtime/runner/chart-resolver.js +16 -62
- package/dist/runtime/runner/chart-resolver.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +55 -99
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/dist/runtime/runner/helm-cli.d.ts +3 -1
- package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
- package/dist/runtime/runner/helm-cli.js +9 -7
- package/dist/runtime/runner/helm-cli.js.map +1 -1
- package/dist/runtime/runner/helm-runner.d.ts +4 -4
- package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
- package/dist/runtime/runner/helm-runner.js +18 -16
- package/dist/runtime/runner/helm-runner.js.map +1 -1
- package/dist/runtime/sources/kinds/git.d.ts +1 -2
- package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
- package/dist/runtime/sources/kinds/git.js +8 -5
- package/dist/runtime/sources/kinds/git.js.map +1 -1
- package/dist/runtime/sources/manifest-walk.d.ts +3 -1
- package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
- package/dist/runtime/sources/manifest-walk.js +8 -3
- package/dist/runtime/sources/manifest-walk.js.map +1 -1
- package/dist/runtime/sources/schema.d.ts +3 -0
- package/dist/runtime/sources/schema.d.ts.map +1 -1
- package/dist/runtime/sources/schema.js +6 -1
- package/dist/runtime/sources/schema.js.map +1 -1
- package/dist/runtime/supervisor.d.ts +2 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +1 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +315 -0
- package/edition/assemble-producer-input.mjs +1251 -0
- package/edition/authorization-base.mjs +347 -0
- package/edition/biome-content.mjs +268 -0
- package/edition/build-biome-index.mjs +175 -0
- package/edition/build-platform-service-index.mjs +288 -0
- package/edition/build-runtime-image-source-index.mjs +821 -0
- package/edition/built-verifier.mjs +55 -0
- package/edition/catalogue-edition.mjs +49 -0
- package/edition/catalogue-fetch.mjs +64 -0
- package/edition/catalogue.mjs +135 -0
- package/edition/chart-set-source-commit.mjs +92 -0
- package/edition/check-committed-image-evidence.mjs +383 -0
- package/edition/checkout-indexed-source-repositories.mjs +105 -0
- package/edition/comment-blanker.mjs +75 -0
- package/edition/connector-source-authority.mjs +60 -0
- package/edition/customer-definition.mjs +183 -0
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-customer-edition.mjs +99 -0
- package/edition/develop-edition-resolve.mjs +65 -0
- package/edition/develop-edition.mjs +274 -0
- package/edition/develop-freeze.mjs +173 -0
- package/edition/develop-image-coordinate.mjs +54 -0
- package/edition/develop-seal.mjs +77 -0
- package/edition/develop-snapshot-inputs.mjs +104 -0
- package/edition/dist-check.mjs +986 -0
- package/edition/distribution-evidence-set.mjs +987 -0
- package/edition/distribution-paths.mjs +191 -0
- package/edition/edition-artifact.mjs +270 -0
- package/edition/edition-biome-manifests.mjs +85 -0
- package/edition/edition-channel.mjs +6 -0
- package/edition/edition-chart-set.mjs +93 -0
- package/edition/edition-config.mjs +207 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +23 -0
- package/edition/edition-list.mjs +35 -0
- package/edition/edition-locks.mjs +69 -0
- package/edition/edition-protocol.mjs +228 -0
- package/edition/edition-workspace.mjs +176 -0
- package/edition/evidence-checkout.mjs +418 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +256 -0
- package/edition/external-source-walk.mjs +463 -0
- package/edition/first-party-source-ids.mjs +59 -0
- package/edition/fleet-coordinate-finality.mjs +350 -0
- package/edition/git-source-auth.mjs +52 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1225 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3018 -0
- package/edition/no-follow-file.mjs +94 -0
- package/edition/oci-evidence-verbs.mjs +392 -0
- package/edition/oci-image-source-evidence.mjs +239 -0
- package/edition/oras.mjs +648 -0
- package/edition/pin-source.mjs +227 -0
- package/edition/plan-image-evidence-refresh.mjs +408 -0
- package/edition/platform-lock-readability.mjs +892 -0
- package/edition/platform-service-class.mjs +18 -0
- package/edition/platform-service-source-authority.mjs +105 -0
- package/edition/positive-closure.mjs +162 -0
- package/edition/produce-distribution-completeness-evidence.mjs +311 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
- package/edition/produce-image-source-evidence.mjs +765 -0
- package/edition/producer-evidence-bundle.mjs +157 -0
- package/edition/registry-credentials.mjs +193 -0
- package/edition/release-catalogue.mjs +39 -0
- package/edition/release-channel-guard.mjs +99 -0
- package/edition/release-edition-artifact.mjs +158 -0
- package/edition/remedy-command.mjs +89 -0
- package/edition/repin-evidence-trust.mjs +101 -0
- package/edition/required-resource-provider.mjs +58 -0
- package/edition/resolve-chart-set.mjs +415 -0
- package/edition/resolve-customer-edition.mjs +288 -0
- package/edition/resolve-distribution.mjs +2318 -0
- package/edition/resolve-edition.mjs +186 -0
- package/edition/resolve-images.mjs +1606 -0
- package/edition/seal-image-digests.mjs +855 -0
- package/edition/sealed-evidence-accept.mjs +82 -0
- package/edition/source-authority.mjs +725 -0
- package/edition/source-closure.mjs +116 -0
- package/edition/strict-json.mjs +121 -0
- package/edition/string-order.mjs +5 -0
- package/edition/trust-policy-arming.mjs +120 -0
- package/edition/trusted-executable.mjs +166 -0
- package/edition/verifier-binding.mjs +45 -0
- package/edition/verifier-files.mjs +8 -0
- package/edition/verifier-kernel-schemas.mjs +22 -0
- package/edition/verifier-provenance.mjs +64 -0
- package/edition/verify-image-evidence.mjs +430 -0
- package/edition/verify-image-platforms.mjs +495 -0
- package/edition/why.mjs +56 -0
- package/edition/workspace-sections.mjs +195 -0
- package/examples/06-biome-sources.yaml +1 -0
- package/package.json +14 -6
- package/dist/commands/dist.d.ts +0 -16
- package/dist/commands/dist.d.ts.map +0 -1
- package/dist/commands/dist.js +0 -182
- package/dist/commands/dist.js.map +0 -1
- package/dist/runtime/distribution/available-biome.d.ts +0 -10
- package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
- package/dist/runtime/distribution/available-biome.js +0 -156
- package/dist/runtime/distribution/available-biome.js.map +0 -1
- package/dist/runtime/distribution/compose.d.ts +0 -23
- package/dist/runtime/distribution/compose.d.ts.map +0 -1
- package/dist/runtime/distribution/compose.js +0 -48
- package/dist/runtime/distribution/compose.js.map +0 -1
- package/dist/runtime/runner/chart-trust.d.ts +0 -57
- package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
- package/dist/runtime/runner/chart-trust.js +0 -270
- package/dist/runtime/runner/chart-trust.js.map +0 -1
package/edition/oras.mjs
ADDED
|
@@ -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
|
+
}
|