vana-cli 0.24.5 → 0.25.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/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +91 -12
- package/dist/cli/index.js.map +1 -1
- package/dist/connectors/registry.d.ts +2 -0
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +18 -2
- package/dist/connectors/registry.js.map +1 -1
- package/dist/core/cli-types.d.ts +44 -0
- package/dist/core/cli-types.d.ts.map +1 -1
- package/dist/core/cli-types.js +14 -0
- package/dist/core/cli-types.js.map +1 -1
- package/dist/core/state-store.d.ts +6 -0
- package/dist/core/state-store.d.ts.map +1 -1
- package/dist/core/state-store.js.map +1 -1
- package/dist/pdpp/host.d.ts +66 -0
- package/dist/pdpp/host.d.ts.map +1 -0
- package/dist/pdpp/host.js +283 -0
- package/dist/pdpp/host.js.map +1 -0
- package/dist/pdpp/installer.d.ts +17 -0
- package/dist/pdpp/installer.d.ts.map +1 -0
- package/dist/pdpp/installer.js +117 -0
- package/dist/pdpp/installer.js.map +1 -0
- package/dist/pdpp/local.d.ts +14 -0
- package/dist/pdpp/local.d.ts.map +1 -0
- package/dist/pdpp/local.js +76 -0
- package/dist/pdpp/local.js.map +1 -0
- package/dist/pdpp/pins.d.ts +23 -0
- package/dist/pdpp/pins.d.ts.map +1 -0
- package/dist/pdpp/pins.js +18 -0
- package/dist/pdpp/pins.js.map +1 -0
- package/dist/pdpp/profile.d.ts +49 -0
- package/dist/pdpp/profile.d.ts.map +1 -0
- package/dist/pdpp/profile.js +17 -0
- package/dist/pdpp/profile.js.map +1 -0
- package/dist/pdpp/protocol.d.ts +79 -0
- package/dist/pdpp/protocol.d.ts.map +1 -0
- package/dist/pdpp/protocol.js +372 -0
- package/dist/pdpp/protocol.js.map +1 -0
- package/dist/pdpp/runtime.d.ts +43 -0
- package/dist/pdpp/runtime.d.ts.map +1 -0
- package/dist/pdpp/runtime.js +387 -0
- package/dist/pdpp/runtime.js.map +1 -0
- package/dist/pdpp/store.d.ts +43 -0
- package/dist/pdpp/store.d.ts.map +1 -0
- package/dist/pdpp/store.js +100 -0
- package/dist/pdpp/store.js.map +1 -0
- package/dist/runtime/core/contracts.d.ts +2 -0
- package/dist/runtime/core/contracts.d.ts.map +1 -1
- package/dist/runtime/managed-playwright.d.ts +7 -0
- package/dist/runtime/managed-playwright.d.ts.map +1 -1
- package/dist/runtime/managed-playwright.js +7 -0
- package/dist/runtime/managed-playwright.js.map +1 -1
- package/dist/vendor/pdpp-connector-manager/LICENSE +202 -0
- package/dist/vendor/pdpp-connector-manager/NOTICE +2 -0
- package/dist/vendor/pdpp-connector-manager/VENDOR.md +27 -0
- package/dist/vendor/pdpp-connector-manager/catalog-schema-data.mjs +168 -0
- package/dist/vendor/pdpp-connector-manager/catalog-schema.mjs +62 -0
- package/dist/vendor/pdpp-connector-manager/index.d.mts +49 -0
- package/dist/vendor/pdpp-connector-manager/index.mjs +1646 -0
- package/dist/vendor/pdpp-connector-manager/oci-catalog.mjs +93 -0
- package/dist/vendor/pdpp-connector-manager/oci-registry.mjs +1002 -0
- package/dist/vendor/pdpp-connector-manager/oci-verify.mjs +515 -0
- package/dist/vendor/pdpp-connector-manager/package.json +51 -0
- package/dist/vendor/pdpp-connector-manager/retry.mjs +142 -0
- package/dist/vendor/pdpp-connector-manager/tar-stream.mjs +685 -0
- package/package.json +7 -2
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
// Copyright The PDP-Connect Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// The registry half of OCI connector consumption: turn a reference into bytes,
|
|
5
|
+
// and say honestly when it could not.
|
|
6
|
+
//
|
|
7
|
+
// The classification core — present / absent / unknown — is carried over from
|
|
8
|
+
// the publisher's `scripts/lookup-manifest.mjs`, and the reason it is carried
|
|
9
|
+
// over rather than re-derived is that the distinction it draws is the whole
|
|
10
|
+
// point of the module. A publisher asks "may I move this version tag onto new
|
|
11
|
+
// bytes?"; a consumer asks "should I install these bytes?". Both questions are
|
|
12
|
+
// answered wrongly, and in the same direction, by any client that reports "I
|
|
13
|
+
// could not find out" as "nothing is there". The publisher's version of this
|
|
14
|
+
// bug authorised re-signing a released tag; the consumer's would let a sick
|
|
15
|
+
// registry look like a withdrawn connector. One rule, two callers.
|
|
16
|
+
//
|
|
17
|
+
// What the publisher's copy classified from is kept exactly: THE RESPONSE TO
|
|
18
|
+
// THE REQUEST THAT WAS MADE. Absence is a claim about one endpoint, so it is
|
|
19
|
+
// only ever read off that endpoint's own reply, never off a token exchange's
|
|
20
|
+
// failure and never off registry prose. `parseDistributionErrorCodes` and
|
|
21
|
+
// `classifyManifestResponse` below are that rule, and their tests come with
|
|
22
|
+
// them (`oci-registry.test.mjs`).
|
|
23
|
+
//
|
|
24
|
+
// WHAT IS NEW HERE, AND WHY. The publisher only ever needed a digest. A
|
|
25
|
+
// consumer needs the bytes too, so this module adds blob fetch, and with it
|
|
26
|
+
// two obligations the publisher never had:
|
|
27
|
+
//
|
|
28
|
+
// - a blob is verified against the digest that NAMED it before any caller
|
|
29
|
+
// sees it (`fetchBlob`). A registry that serves the wrong bytes for a
|
|
30
|
+
// content-addressed name is not trusted to say so itself.
|
|
31
|
+
// - a fetch by digest never falls back to a tag. Re-resolution is the defect
|
|
32
|
+
// the publisher documents at its own push step, and the consumer's version
|
|
33
|
+
// of it is installing something other than what the lock pinned.
|
|
34
|
+
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
|
|
37
|
+
import { fetchWithRetry } from "./retry.mjs";
|
|
38
|
+
|
|
39
|
+
const MANIFEST_ACCEPT = [
|
|
40
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
41
|
+
"application/vnd.oci.image.index.v1+json",
|
|
42
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
43
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
44
|
+
].join(", ");
|
|
45
|
+
|
|
46
|
+
// The only two codes the distribution spec defines for "this reference does not
|
|
47
|
+
// exist". Anything else a registry returns with a 404 — including a bare 404
|
|
48
|
+
// from a proxy that never reached the registry — stays UNKNOWN.
|
|
49
|
+
const ABSENCE_CODES = new Set(["MANIFEST_UNKNOWN", "NAME_UNKNOWN"]);
|
|
50
|
+
|
|
51
|
+
// A manifest descriptor is small, and so are the distribution-spec error bodies
|
|
52
|
+
// classified from it. 1 MiB is far above anything either can legitimately be, so
|
|
53
|
+
// a response past it is not a reply this module can read. The ceiling exists
|
|
54
|
+
// because the body is accumulated in memory.
|
|
55
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
// A connector artifact's layers are code and a brand icon, not container images.
|
|
58
|
+
// 64 MiB bounds a single blob well above anything the builder emits while still
|
|
59
|
+
// refusing to buffer an unbounded stream from a peer.
|
|
60
|
+
const MAX_BLOB_BYTES = 64 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
export const DEFAULT_OCI_REGISTRY = "ghcr.io";
|
|
63
|
+
|
|
64
|
+
// The registry this consumer will talk to at all. C1.3: a lock entry naming any
|
|
65
|
+
// other host is refused before a socket is opened, which is the fail-closed
|
|
66
|
+
// analogue of the tarball path's URL→identity origin policy. Widening this is a
|
|
67
|
+
// deliberate edit, not something an artifact or a lock file can ask for.
|
|
68
|
+
const ALLOWED_REGISTRIES = new Set([DEFAULT_OCI_REGISTRY]);
|
|
69
|
+
|
|
70
|
+
// The same predicate the publisher enforces when it derives a repository name
|
|
71
|
+
// from a manifest's `connector_key`. Checked here so a key that could never
|
|
72
|
+
// have been published is refused before any network call (C1.1).
|
|
73
|
+
const CONNECTOR_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
74
|
+
|
|
75
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
76
|
+
|
|
77
|
+
export function isValidConnectorKey(key) {
|
|
78
|
+
return typeof key === "string" && CONNECTOR_KEY_PATTERN.test(key);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isValidDigest(digest) {
|
|
82
|
+
return typeof digest === "string" && DIGEST_PATTERN.test(digest.trim());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function sha256Hex(buffer) {
|
|
86
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function sha256Digest(buffer) {
|
|
90
|
+
return `sha256:${sha256Hex(buffer)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The cosign tag that holds the signature for a manifest digest.
|
|
95
|
+
*
|
|
96
|
+
* `sha256:<hex>` is not a legal tag — `:` is a separator — so cosign rewrites
|
|
97
|
+
* the separator and suffixes `.sig`. Observed against cosign v2.4.3, which is
|
|
98
|
+
* the version the publish workflow pins.
|
|
99
|
+
*/
|
|
100
|
+
export function cosignSignatureTag(digest) {
|
|
101
|
+
if (!isValidDigest(digest)) {
|
|
102
|
+
throw new Error(`Cannot derive a cosign signature tag from "${digest}"`);
|
|
103
|
+
}
|
|
104
|
+
return `${digest.trim().replace(":", "-")}.sig`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A refusal that carries WHICH failure it was.
|
|
109
|
+
*
|
|
110
|
+
* C6.1 requires unknown / absent / denied / tampered / misidentified /
|
|
111
|
+
* unverifiable to be distinguishable, and a caller that has to regex an error
|
|
112
|
+
* message to tell them apart cannot act on the difference. The `reason` tag is
|
|
113
|
+
* the machine-readable half; the message stays human-readable.
|
|
114
|
+
*/
|
|
115
|
+
export class OciRegistryError extends Error {
|
|
116
|
+
constructor(message, reason) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = "OciRegistryError";
|
|
119
|
+
this.reason = reason;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate and split `ghcr.io/pdp-connect/connector/<key>` style coordinates.
|
|
125
|
+
*
|
|
126
|
+
* Every field a caller can influence is checked here, before anything is sent,
|
|
127
|
+
* so that a malformed lock entry fails as a refusal rather than as a request to
|
|
128
|
+
* somewhere unintended.
|
|
129
|
+
*/
|
|
130
|
+
export function parseOciReference({
|
|
131
|
+
registry,
|
|
132
|
+
repository,
|
|
133
|
+
digest = null,
|
|
134
|
+
version = null,
|
|
135
|
+
// The behavioural tests' hook, and nothing else. It exists because the
|
|
136
|
+
// acceptance suite serves a real registry on a loopback port, and a test
|
|
137
|
+
// registry cannot be `ghcr.io`. It is passed explicitly by a caller that
|
|
138
|
+
// already decided to trust it — never read from the environment, never
|
|
139
|
+
// inferred, and never reachable from a lock file, so no artifact or lock can
|
|
140
|
+
// talk the installer into widening the policy.
|
|
141
|
+
allowedRegistries = ALLOWED_REGISTRIES,
|
|
142
|
+
} = {}) {
|
|
143
|
+
if (typeof registry !== "string" || registry.length === 0) {
|
|
144
|
+
throw new OciRegistryError("OCI entry is missing a registry", "invalid-reference");
|
|
145
|
+
}
|
|
146
|
+
if (!allowedRegistries.has(registry)) {
|
|
147
|
+
throw new OciRegistryError(
|
|
148
|
+
`Refusing connector artifact from unsupported registry "${registry}"; only ${[...allowedRegistries].join(", ")} is trusted`,
|
|
149
|
+
"untrusted-registry"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (typeof repository !== "string" || !/^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*$/.test(repository)) {
|
|
153
|
+
throw new OciRegistryError(
|
|
154
|
+
`Invalid OCI repository "${repository}"`,
|
|
155
|
+
"invalid-reference"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (digest !== null && !isValidDigest(digest)) {
|
|
159
|
+
throw new OciRegistryError(`Invalid OCI manifest digest "${digest}"`, "invalid-reference");
|
|
160
|
+
}
|
|
161
|
+
if (digest === null && (typeof version !== "string" || version.length === 0)) {
|
|
162
|
+
throw new OciRegistryError(
|
|
163
|
+
`OCI entry for ${repository} carries neither a digest nor a version`,
|
|
164
|
+
"invalid-reference"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
registry,
|
|
169
|
+
repository,
|
|
170
|
+
digest: digest === null ? null : digest.trim(),
|
|
171
|
+
version,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse a user-typed reference — `ghcr.io/pdp-connect/connector/ynab@sha256:…`
|
|
177
|
+
* or `…/ynab:0.3.0` — into coordinates.
|
|
178
|
+
*
|
|
179
|
+
* The `@digest` form is parsed before the `:tag` form, because a digest
|
|
180
|
+
* contains a colon and reading the reference right-to-left for a tag would
|
|
181
|
+
* split `sha256:abc…` in the middle. The connector key is the last path
|
|
182
|
+
* segment, and it is validated here so a reference that could never name a
|
|
183
|
+
* published artifact is refused before it becomes a request (C1.1).
|
|
184
|
+
*/
|
|
185
|
+
export function parseConnectorOciReference(reference) {
|
|
186
|
+
if (typeof reference !== "string" || reference.length === 0) {
|
|
187
|
+
throw new OciRegistryError("No OCI reference given", "invalid-reference");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const at = reference.indexOf("@");
|
|
191
|
+
let body = reference;
|
|
192
|
+
let digest = null;
|
|
193
|
+
let version = null;
|
|
194
|
+
|
|
195
|
+
if (at !== -1) {
|
|
196
|
+
body = reference.slice(0, at);
|
|
197
|
+
digest = reference.slice(at + 1);
|
|
198
|
+
if (!isValidDigest(digest)) {
|
|
199
|
+
throw new OciRegistryError(`Invalid digest in OCI reference "${reference}"`, "invalid-reference");
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
const lastColon = body.lastIndexOf(":");
|
|
203
|
+
const lastSlash = body.lastIndexOf("/");
|
|
204
|
+
if (lastColon > lastSlash) {
|
|
205
|
+
version = body.slice(lastColon + 1);
|
|
206
|
+
body = body.slice(0, lastColon);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const firstSlash = body.indexOf("/");
|
|
211
|
+
if (firstSlash === -1) {
|
|
212
|
+
throw new OciRegistryError(
|
|
213
|
+
`OCI reference "${reference}" names no repository`,
|
|
214
|
+
"invalid-reference"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const registry = body.slice(0, firstSlash);
|
|
218
|
+
const repository = body.slice(firstSlash + 1);
|
|
219
|
+
const connectorKey = repository.slice(repository.lastIndexOf("/") + 1);
|
|
220
|
+
|
|
221
|
+
if (!isValidConnectorKey(connectorKey)) {
|
|
222
|
+
throw new OciRegistryError(
|
|
223
|
+
`OCI reference "${reference}" names an invalid connector key "${connectorKey}"`,
|
|
224
|
+
"invalid-reference"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Runs the same registry and repository checks a lock entry gets, so the
|
|
229
|
+
// command line is not a way around the origin policy.
|
|
230
|
+
parseOciReference({ registry, repository, digest, version });
|
|
231
|
+
|
|
232
|
+
return { registry, repository, connectorKey, digest, version };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."`
|
|
237
|
+
* challenge. Returns null for any other scheme, which keeps an unexpected
|
|
238
|
+
* challenge on the unknown path rather than guessing at a token exchange.
|
|
239
|
+
*/
|
|
240
|
+
export function parseBearerChallenge(header) {
|
|
241
|
+
if (typeof header !== "string") return null;
|
|
242
|
+
if (!/^bearer\s/i.test(header)) return null;
|
|
243
|
+
const params = {};
|
|
244
|
+
for (const match of header.slice(7).matchAll(/([a-zA-Z0-9_-]+)="([^"]*)"/g)) {
|
|
245
|
+
params[match[1]] = match[2];
|
|
246
|
+
}
|
|
247
|
+
return params.realm ? params : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Read a distribution-spec error code out of a response body.
|
|
252
|
+
*
|
|
253
|
+
* Strict on purpose, and carried over verbatim in intent from the publisher's
|
|
254
|
+
* lookup. The body must be JSON, must carry an `errors` array, and codes are
|
|
255
|
+
* taken ONLY from that structure — never from a free-text `message`, which is
|
|
256
|
+
* where the publisher's original defect lived.
|
|
257
|
+
*
|
|
258
|
+
* An entry WITHOUT a string `code` is not skipped, it poisons the whole array:
|
|
259
|
+
* `[{"code":"MANIFEST_UNKNOWN"},{}]` must not reduce to a clean absence, because
|
|
260
|
+
* the unreadable second error may be the one saying the request was not allowed
|
|
261
|
+
* to ask. `null` is that verdict — distinct from `[]`, which says the body
|
|
262
|
+
* carried no error structure — and both land on `unknown`.
|
|
263
|
+
*/
|
|
264
|
+
export function parseDistributionErrorCodes(body) {
|
|
265
|
+
let parsed;
|
|
266
|
+
try {
|
|
267
|
+
parsed = JSON.parse(body);
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
if (!parsed || !Array.isArray(parsed.errors)) return [];
|
|
272
|
+
const codes = [];
|
|
273
|
+
for (const entry of parsed.errors) {
|
|
274
|
+
if (!entry || typeof entry.code !== "string") return null;
|
|
275
|
+
codes.push(entry.code.toUpperCase());
|
|
276
|
+
}
|
|
277
|
+
return codes;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The registry's own words for a failure, for the diagnostic line only.
|
|
282
|
+
*
|
|
283
|
+
* Deliberately NEVER consulted by the classifier: this text is precisely the
|
|
284
|
+
* prose whose promotion to a decision was the defect being avoided. A 403 whose
|
|
285
|
+
* message reads "not found" is still a 403.
|
|
286
|
+
*/
|
|
287
|
+
function registryMessage(body) {
|
|
288
|
+
let parsed;
|
|
289
|
+
try {
|
|
290
|
+
parsed = JSON.parse(body);
|
|
291
|
+
} catch {
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
294
|
+
const message = parsed?.errors?.[0]?.message;
|
|
295
|
+
return typeof message === "string" ? message : "";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Classify the response to THE manifest request.
|
|
300
|
+
*
|
|
301
|
+
* Split out from the I/O so the decision table is testable without a socket. A
|
|
302
|
+
* token failure never reaches this function; it is handled where the token is
|
|
303
|
+
* requested, which is what makes "a token 404 cannot become an absence" a
|
|
304
|
+
* structural property rather than a filtered one.
|
|
305
|
+
*/
|
|
306
|
+
export function classifyManifestResponse(response) {
|
|
307
|
+
const { status, headers = {}, body = "" } = response;
|
|
308
|
+
|
|
309
|
+
if (status === 200) {
|
|
310
|
+
const digest = headers["docker-content-digest"];
|
|
311
|
+
const computed = sha256Digest(Buffer.from(body, "utf8"));
|
|
312
|
+
if (typeof digest === "string" && isValidDigest(digest)) {
|
|
313
|
+
// C2.1: the header and the bytes must agree. They are two independent
|
|
314
|
+
// claims about the same object, and a registry that contradicts itself
|
|
315
|
+
// about a content-addressed name has not answered the question.
|
|
316
|
+
if (digest.trim() !== computed) {
|
|
317
|
+
return {
|
|
318
|
+
outcome: "unknown",
|
|
319
|
+
reason:
|
|
320
|
+
`the manifest endpoint's Docker-Content-Digest (${digest.trim()}) does not match ` +
|
|
321
|
+
`the digest of the bytes it returned (${computed})`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { outcome: "present", digest: digest.trim(), body };
|
|
325
|
+
}
|
|
326
|
+
if (digest === undefined) {
|
|
327
|
+
// A registry that omits the header entirely is still answerable: the
|
|
328
|
+
// digest is a property of the bytes, and they are in hand.
|
|
329
|
+
return { outcome: "present", digest: computed, body };
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
outcome: "unknown",
|
|
333
|
+
reason: "the manifest endpoint returned 200 with a malformed Docker-Content-Digest header",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (status === 404) {
|
|
338
|
+
const codes = parseDistributionErrorCodes(body);
|
|
339
|
+
const said = registryMessage(body);
|
|
340
|
+
|
|
341
|
+
if (codes === null) {
|
|
342
|
+
return {
|
|
343
|
+
outcome: "unknown",
|
|
344
|
+
reason:
|
|
345
|
+
`the manifest endpoint returned 404 with an errors array carrying an entry that has no ` +
|
|
346
|
+
`string code, so the reply cannot be read as absence and only absence` +
|
|
347
|
+
`${said ? ` (registry said: ${said})` : ""}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// EVERY code must be an absence code, and there must be at least one. A
|
|
352
|
+
// body mixing MANIFEST_UNKNOWN with DENIED asserts two different things,
|
|
353
|
+
// one of which says the request was not allowed to ask; a reply that
|
|
354
|
+
// contradicts itself has not established that this manifest is missing.
|
|
355
|
+
if (codes.length > 0 && codes.every((code) => ABSENCE_CODES.has(code))) {
|
|
356
|
+
return { outcome: "absent" };
|
|
357
|
+
}
|
|
358
|
+
const contradictory = codes.some((code) => ABSENCE_CODES.has(code));
|
|
359
|
+
return {
|
|
360
|
+
outcome: "unknown",
|
|
361
|
+
reason:
|
|
362
|
+
`the manifest endpoint returned 404 but its body does not state absence and only absence: ` +
|
|
363
|
+
(contradictory
|
|
364
|
+
? `it mixes an absence code with ${codes.filter((code) => !ABSENCE_CODES.has(code)).join(", ")}, ` +
|
|
365
|
+
`so the reply contradicts itself`
|
|
366
|
+
: `it carries no MANIFEST_UNKNOWN or NAME_UNKNOWN distribution error`) +
|
|
367
|
+
` (codes: ${codes.length ? codes.join(", ") : "none"}${said ? `; registry said: ${said}` : ""})`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 401/403 say the request was not allowed to ask, not that the answer is no.
|
|
372
|
+
// 5xx says the registry failed. Both are unknown, and the STATUS takes
|
|
373
|
+
// precedence over any message text.
|
|
374
|
+
const said = registryMessage(body);
|
|
375
|
+
return {
|
|
376
|
+
outcome: "unknown",
|
|
377
|
+
reason: `the manifest endpoint returned HTTP ${status}${said ? ` (registry said: ${said})` : ""}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* One HTTP round trip with the body collected, bounded by ONE deadline for the
|
|
383
|
+
* whole exchange.
|
|
384
|
+
*
|
|
385
|
+
* `AbortSignal.timeout` measures elapsed time, not socket inactivity, which is
|
|
386
|
+
* the property needed: a peer dribbling a byte every few seconds must not be
|
|
387
|
+
* able to hold an install open indefinitely.
|
|
388
|
+
*/
|
|
389
|
+
async function fetchOnce(
|
|
390
|
+
url,
|
|
391
|
+
{
|
|
392
|
+
method = "GET",
|
|
393
|
+
headers = {},
|
|
394
|
+
timeoutMs = 30000,
|
|
395
|
+
maxBytes = MAX_MANIFEST_BYTES,
|
|
396
|
+
fetchImpl = fetch,
|
|
397
|
+
redirect = "follow",
|
|
398
|
+
retryOptions = {},
|
|
399
|
+
} = {}
|
|
400
|
+
) {
|
|
401
|
+
const response = await fetchWithRetry(url, {
|
|
402
|
+
...retryOptions,
|
|
403
|
+
fetchImpl,
|
|
404
|
+
fetchOptions: () => ({
|
|
405
|
+
method,
|
|
406
|
+
headers: { "user-agent": "pdpp-connector-installer/1", ...headers },
|
|
407
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
408
|
+
redirect,
|
|
409
|
+
}),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
413
|
+
if (buffer.length > maxBytes) {
|
|
414
|
+
// Refused rather than truncated: a half-read JSON body parses to nothing,
|
|
415
|
+
// which is indistinguishable from a registry that sent no error code.
|
|
416
|
+
//
|
|
417
|
+
// Note what this does and does not bound. The body is already buffered by
|
|
418
|
+
// the time it is measured, so this caps what a CALLER can be handed, not
|
|
419
|
+
// peak memory during the read. What bounds an endlessly streaming peer is
|
|
420
|
+
// the elapsed-time deadline above, not this. Stating it because a reader
|
|
421
|
+
// who assumed otherwise would be relying on a guarantee that is not here.
|
|
422
|
+
throw new Error(`response body exceeded ${maxBytes} bytes`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const headerObject = {};
|
|
426
|
+
response.headers?.forEach?.((value, key) => {
|
|
427
|
+
headerObject[key.toLowerCase()] = value;
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return { status: response.status, headers: headerObject, buffer, body: buffer.toString("utf8") };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// A 3xx this consumer will follow by hand. 304 is not a redirect to a location.
|
|
434
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
435
|
+
|
|
436
|
+
// Enough hops for a realm that redirects once or twice; a chain longer than this
|
|
437
|
+
// is a loop or a service this consumer should not be chasing.
|
|
438
|
+
const MAX_TOKEN_REDIRECTS = 3;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The token request, following redirects MANUALLY so every hop is checked.
|
|
442
|
+
*
|
|
443
|
+
* `redirect: "follow"` let the origin policy be satisfied once and then left
|
|
444
|
+
* behind: an allowed realm could answer 302 to any origin and the runtime would
|
|
445
|
+
* fetch it without the challenge check ever seeing that second origin. Since the
|
|
446
|
+
* realm is supplied by the peer in its own 401, that made the check advisory —
|
|
447
|
+
* the registry chose the final destination, including a port on the machine
|
|
448
|
+
* running the install.
|
|
449
|
+
*
|
|
450
|
+
* So the redirect is not followed by the runtime; each `Location` is resolved
|
|
451
|
+
* and put through the SAME `checkTokenRealm` the first realm passed, and a hop
|
|
452
|
+
* that fails is a refusal rather than a request. This is the token exchange
|
|
453
|
+
* only. Blob and manifest reads keep `redirect: "follow"`, because a registry
|
|
454
|
+
* redirecting a blob to its CDN is the documented way that transport works and
|
|
455
|
+
* those responses are pinned by digest rather than trusted by origin.
|
|
456
|
+
*/
|
|
457
|
+
async function fetchTokenFollowingRedirects(
|
|
458
|
+
startUrl,
|
|
459
|
+
{ registry, timeoutMs, fetchImpl, allowInsecureLoopback, retryOptions }
|
|
460
|
+
) {
|
|
461
|
+
let url = startUrl;
|
|
462
|
+
|
|
463
|
+
for (let hop = 0; hop <= MAX_TOKEN_REDIRECTS; hop += 1) {
|
|
464
|
+
const response = await fetchOnce(url.toString(), {
|
|
465
|
+
timeoutMs,
|
|
466
|
+
fetchImpl,
|
|
467
|
+
redirect: "manual",
|
|
468
|
+
retryOptions,
|
|
469
|
+
});
|
|
470
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
471
|
+
return { response };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const location = response.headers.location;
|
|
475
|
+
if (!location) {
|
|
476
|
+
return { error: `the token endpoint returned HTTP ${response.status} without a location` };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let next;
|
|
480
|
+
try {
|
|
481
|
+
// Resolved against the current URL, so a relative location is read the
|
|
482
|
+
// same way the runtime would have read it.
|
|
483
|
+
next = new URL(location, url);
|
|
484
|
+
} catch {
|
|
485
|
+
return { error: "the token endpoint redirected to a location that cannot be read as a URL" };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const refusal = checkTokenRealm(next, registry, { allowInsecureLoopback });
|
|
489
|
+
if (refusal) {
|
|
490
|
+
return { error: `the token endpoint redirected to a refused origin: ${refusal}` };
|
|
491
|
+
}
|
|
492
|
+
url = next;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return { error: `the token endpoint redirected more than ${MAX_TOKEN_REDIRECTS} times` };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The token realms this consumer will talk to, keyed by registry origin. Moved
|
|
499
|
+
// from the publisher's `lookup-manifest.mjs` (#97) rather than rewritten, so
|
|
500
|
+
// both sides of the same handshake apply the same policy.
|
|
501
|
+
//
|
|
502
|
+
// The unit is the ORIGIN — scheme, host AND port — because a hostname is not a
|
|
503
|
+
// service. `https://ghcr.io:9443/token` is a different listener from the
|
|
504
|
+
// registry at `ghcr.io`, and a registry on port 5000 challenging to a realm on
|
|
505
|
+
// 6000 is naming whatever else is bound on that machine. Origins are compared
|
|
506
|
+
// as WHATWG `URL` renders them, which is what makes the comparison total: it
|
|
507
|
+
// defaults the port per scheme (`https://ghcr.io:443` IS `https://ghcr.io`),
|
|
508
|
+
// lowercases the host, and brackets and compresses IPv6 literals identically on
|
|
509
|
+
// both sides (`[0:0:0:0:0:0:0:1]` IS `[::1]`), so no spelling of an authority
|
|
510
|
+
// slips past by differing from the registry's.
|
|
511
|
+
//
|
|
512
|
+
// GHCR is the split this repository pulls from: ghcr.io challenges with a realm
|
|
513
|
+
// on ghcr.io itself. Docker Hub is kept because the same helper resolves any
|
|
514
|
+
// `<registry>/<name>:<tag>`. Both sides are https origins, because a documented
|
|
515
|
+
// auth service is a public one reached over TLS on the default port —
|
|
516
|
+
// `ghcr.io:9443` is not the GHCR this table describes and does not inherit its
|
|
517
|
+
// realms. Widening this is a deliberate edit, not an accident of a challenge.
|
|
518
|
+
const TOKEN_ORIGINS = new Map([
|
|
519
|
+
["https://ghcr.io", ["https://ghcr.io"]],
|
|
520
|
+
["https://registry-1.docker.io", ["https://auth.docker.io"]],
|
|
521
|
+
["https://docker.io", ["https://auth.docker.io"]],
|
|
522
|
+
["https://index.docker.io", ["https://auth.docker.io"]],
|
|
523
|
+
]);
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The registry authority read as an origin under a given scheme.
|
|
527
|
+
*
|
|
528
|
+
* Both sides of the destination check go through `URL` so they are normalised
|
|
529
|
+
* the same way; returns null when the authority is not one `URL` can read,
|
|
530
|
+
* which the caller turns into a refusal rather than a comparison against a
|
|
531
|
+
* string it had to build by hand.
|
|
532
|
+
*/
|
|
533
|
+
function originOf(scheme, authority) {
|
|
534
|
+
try {
|
|
535
|
+
const url = new URL(`${scheme}//${authority}`);
|
|
536
|
+
return url.origin === "null" ? null : url.origin;
|
|
537
|
+
} catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* May this consumer make the token request to this realm?
|
|
544
|
+
*
|
|
545
|
+
* Returns null when it may, or a diagnostic sentence when it may not. Callers
|
|
546
|
+
* turn a refusal into `unknown`: the manifest question is unanswered, and an
|
|
547
|
+
* unanswered question must never read as absence.
|
|
548
|
+
*
|
|
549
|
+
* The publisher checks this to protect a credential. This consumer sends none —
|
|
550
|
+
* the pull is anonymous — so what the check is worth here is smaller and worth
|
|
551
|
+
* stating exactly: the realm comes from the PEER, in its own 401, so without
|
|
552
|
+
* the check a registry chooses an arbitrary origin this process will then issue
|
|
553
|
+
* a GET to, including a port on the machine running the install. That is a
|
|
554
|
+
* request forgery with the registry as the author, not a credential leak. It is
|
|
555
|
+
* a narrow exposure, and it costs one comparison to close.
|
|
556
|
+
*
|
|
557
|
+
* `allowInsecureLoopback` is the tests' hook and nothing else — off unless the
|
|
558
|
+
* caller passes it, so the fixture registry's plaintext realm is reachable in a
|
|
559
|
+
* test and no loopback carve-out exists in production. What it waives is the
|
|
560
|
+
* TRANSPORT requirement, for a loopback host only; the destination check still
|
|
561
|
+
* runs, so a test registry cannot be talked into a different loopback port than
|
|
562
|
+
* the one it is published on.
|
|
563
|
+
*/
|
|
564
|
+
export function checkTokenRealm(realmUrl, registry, { allowInsecureLoopback = false } = {}) {
|
|
565
|
+
const registryAuthority = registry.split("/")[0].toLowerCase();
|
|
566
|
+
// `URL` keeps the brackets on an IPv6 literal, so the loopback test is
|
|
567
|
+
// written against the bracketed spelling rather than the bare address.
|
|
568
|
+
const realmHost = realmUrl.hostname.toLowerCase();
|
|
569
|
+
const loopback = realmHost === "127.0.0.1" || realmHost === "[::1]" || realmHost === "localhost";
|
|
570
|
+
|
|
571
|
+
if (realmUrl.protocol !== "https:") {
|
|
572
|
+
// The hook waives the TRANSPORT requirement for loopback and nothing more.
|
|
573
|
+
// Deliberately not an early `return null`: the origin check below still
|
|
574
|
+
// runs.
|
|
575
|
+
const waived = allowInsecureLoopback && realmUrl.protocol === "http:" && loopback;
|
|
576
|
+
if (!waived) {
|
|
577
|
+
return (
|
|
578
|
+
`the 401 challenge points at a non-HTTPS token realm ` +
|
|
579
|
+
`(${realmUrl.protocol}//${realmUrl.host})`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// The registry is read under the REALM'S scheme, so the comparison is between
|
|
585
|
+
// two origins of the same kind. Under the hook that scheme is http, which is
|
|
586
|
+
// the only way a plaintext loopback realm can match at all; on the ordinary
|
|
587
|
+
// path it is https, so a registry written without a port compares equal to a
|
|
588
|
+
// realm written with `:443` and to nothing else.
|
|
589
|
+
const registryOrigin = originOf(realmUrl.protocol, registryAuthority);
|
|
590
|
+
const refusal =
|
|
591
|
+
`the 401 challenge points at ${realmUrl.origin}, which is neither the registry ` +
|
|
592
|
+
`origin (${registryOrigin ?? registryAuthority}) nor a token origin documented for it`;
|
|
593
|
+
|
|
594
|
+
if (registryOrigin === null) return refusal;
|
|
595
|
+
if (realmUrl.origin === registryOrigin) return null;
|
|
596
|
+
|
|
597
|
+
// The documented split-auth table is keyed by the registry's https origin, so
|
|
598
|
+
// it is consulted with that origin whatever scheme the realm proposed.
|
|
599
|
+
const documented = TOKEN_ORIGINS.get(originOf("https:", registryAuthority)) ?? [];
|
|
600
|
+
if (documented.includes(realmUrl.origin)) return null;
|
|
601
|
+
|
|
602
|
+
return refusal;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Anonymous pull of a public GHCR repository still requires a token exchange.
|
|
607
|
+
*
|
|
608
|
+
* C2.4: a failure ANYWHERE in this handshake is the caller's `unknown`, and it
|
|
609
|
+
* is reported as a token failure so it can never be mistaken for the manifest
|
|
610
|
+
* endpoint's answer.
|
|
611
|
+
*/
|
|
612
|
+
async function requestToken(
|
|
613
|
+
challenge,
|
|
614
|
+
{
|
|
615
|
+
registry,
|
|
616
|
+
repository,
|
|
617
|
+
timeoutMs,
|
|
618
|
+
fetchImpl,
|
|
619
|
+
allowInsecureLoopback = false,
|
|
620
|
+
retryOptions,
|
|
621
|
+
}
|
|
622
|
+
) {
|
|
623
|
+
let tokenUrl;
|
|
624
|
+
try {
|
|
625
|
+
tokenUrl = new URL(challenge.realm);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
return { error: `the 401 challenge names an unparseable token realm (${error.message})` };
|
|
628
|
+
}
|
|
629
|
+
const refusal = checkTokenRealm(tokenUrl, registry, { allowInsecureLoopback });
|
|
630
|
+
if (refusal) {
|
|
631
|
+
return { error: refusal };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (challenge.service) tokenUrl.searchParams.set("service", challenge.service);
|
|
635
|
+
tokenUrl.searchParams.set("scope", challenge.scope ?? `repository:${repository}:pull`);
|
|
636
|
+
|
|
637
|
+
let response;
|
|
638
|
+
try {
|
|
639
|
+
const followed = await fetchTokenFollowingRedirects(tokenUrl, {
|
|
640
|
+
registry,
|
|
641
|
+
timeoutMs,
|
|
642
|
+
fetchImpl,
|
|
643
|
+
allowInsecureLoopback,
|
|
644
|
+
retryOptions,
|
|
645
|
+
});
|
|
646
|
+
if (followed.error) {
|
|
647
|
+
return { error: followed.error };
|
|
648
|
+
}
|
|
649
|
+
response = followed.response;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
return { error: `token request failed: ${error.message}` };
|
|
652
|
+
}
|
|
653
|
+
if (response.status !== 200) {
|
|
654
|
+
return {
|
|
655
|
+
error:
|
|
656
|
+
`the token endpoint returned HTTP ${response.status}; ` +
|
|
657
|
+
`this says nothing about whether the manifest exists`,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
let token;
|
|
661
|
+
try {
|
|
662
|
+
const parsed = JSON.parse(response.body);
|
|
663
|
+
token = parsed?.token ?? parsed?.access_token;
|
|
664
|
+
} catch {
|
|
665
|
+
return { error: "the token endpoint returned a body that is not JSON" };
|
|
666
|
+
}
|
|
667
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
668
|
+
return { error: "the token endpoint returned no token" };
|
|
669
|
+
}
|
|
670
|
+
return { token };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* GET one registry path, completing a Bearer handshake if challenged.
|
|
675
|
+
*
|
|
676
|
+
* Returns the raw response so the caller can classify it. Token failures are
|
|
677
|
+
* returned as a `tokenFailure` rather than thrown, so the caller can report
|
|
678
|
+
* them as what they are.
|
|
679
|
+
*/
|
|
680
|
+
async function registryGet(
|
|
681
|
+
{
|
|
682
|
+
registry,
|
|
683
|
+
repository,
|
|
684
|
+
path,
|
|
685
|
+
accept,
|
|
686
|
+
scheme = "https",
|
|
687
|
+
timeoutMs = 30000,
|
|
688
|
+
maxBytes,
|
|
689
|
+
fetchImpl = fetch,
|
|
690
|
+
allowInsecureLoopback = false,
|
|
691
|
+
retryOptions = {},
|
|
692
|
+
}
|
|
693
|
+
) {
|
|
694
|
+
const url = `${scheme}://${registry}/v2/${repository}/${path}`;
|
|
695
|
+
const headers = accept ? { accept } : {};
|
|
696
|
+
|
|
697
|
+
let response = await fetchOnce(url, {
|
|
698
|
+
headers,
|
|
699
|
+
timeoutMs,
|
|
700
|
+
maxBytes,
|
|
701
|
+
fetchImpl,
|
|
702
|
+
retryOptions,
|
|
703
|
+
});
|
|
704
|
+
if (response.status !== 401) return { response };
|
|
705
|
+
|
|
706
|
+
const challenge = parseBearerChallenge(response.headers["www-authenticate"]);
|
|
707
|
+
if (!challenge) {
|
|
708
|
+
return { tokenFailure: "the registry returned 401 without a usable Bearer challenge" };
|
|
709
|
+
}
|
|
710
|
+
const { token, error } = await requestToken(challenge, {
|
|
711
|
+
registry,
|
|
712
|
+
repository,
|
|
713
|
+
timeoutMs,
|
|
714
|
+
fetchImpl,
|
|
715
|
+
retryOptions,
|
|
716
|
+
// `scheme` is ALREADY the explicit, caller-supplied hook this module uses to
|
|
717
|
+
// reach a test registry: production never sets it, so it is `https` on every
|
|
718
|
+
// real path and a plaintext realm is refused there whatever this resolves
|
|
719
|
+
// to. Deriving the waiver from it keeps one hook instead of two that must be
|
|
720
|
+
// set together, and no artifact, lock entry or registry challenge can reach
|
|
721
|
+
// it. The destination check still runs either way, so even under `http` the
|
|
722
|
+
// realm must be the test registry's own origin, port included.
|
|
723
|
+
allowInsecureLoopback: allowInsecureLoopback || scheme === "http",
|
|
724
|
+
});
|
|
725
|
+
if (error) return { tokenFailure: error };
|
|
726
|
+
|
|
727
|
+
response = await fetchOnce(url, {
|
|
728
|
+
headers: { ...headers, authorization: `Bearer ${token}` },
|
|
729
|
+
timeoutMs,
|
|
730
|
+
maxBytes,
|
|
731
|
+
fetchImpl,
|
|
732
|
+
retryOptions,
|
|
733
|
+
});
|
|
734
|
+
return { response };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Resolve `<repository>:<tag>` to one of present / absent / unknown.
|
|
739
|
+
*
|
|
740
|
+
* This is the consumer's copy of the publisher's guard, and it exists for the
|
|
741
|
+
* same reason: a first publish and an unreachable registry look identical from
|
|
742
|
+
* the outside, and only the registry's own answer about this exact reference
|
|
743
|
+
* tells them apart. Returns the classification; refusing is the caller's
|
|
744
|
+
* decision (C2.2), which is why nothing here throws on `unknown`.
|
|
745
|
+
*/
|
|
746
|
+
export async function lookupManifest({
|
|
747
|
+
registry,
|
|
748
|
+
repository,
|
|
749
|
+
reference,
|
|
750
|
+
scheme = "https",
|
|
751
|
+
timeoutMs = 30000,
|
|
752
|
+
fetchImpl = fetch,
|
|
753
|
+
retryOptions = {},
|
|
754
|
+
}) {
|
|
755
|
+
let result;
|
|
756
|
+
try {
|
|
757
|
+
result = await registryGet({
|
|
758
|
+
registry,
|
|
759
|
+
repository,
|
|
760
|
+
path: `manifests/${encodeURIComponent(reference)}`,
|
|
761
|
+
accept: MANIFEST_ACCEPT,
|
|
762
|
+
scheme,
|
|
763
|
+
timeoutMs,
|
|
764
|
+
maxBytes: MAX_MANIFEST_BYTES,
|
|
765
|
+
fetchImpl,
|
|
766
|
+
retryOptions,
|
|
767
|
+
});
|
|
768
|
+
} catch (error) {
|
|
769
|
+
return { outcome: "unknown", reason: `manifest request failed: ${error.message}` };
|
|
770
|
+
}
|
|
771
|
+
if (result.tokenFailure) {
|
|
772
|
+
return { outcome: "unknown", reason: result.tokenFailure };
|
|
773
|
+
}
|
|
774
|
+
return classifyManifestResponse(result.response);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Resolve a version tag to the digest it currently names.
|
|
779
|
+
*
|
|
780
|
+
* C2.1/C2.2: `present` yields a digest, `absent` and `unknown` both refuse, and
|
|
781
|
+
* they refuse with different reasons because the operator's next question
|
|
782
|
+
* differs. This is a FIRST-PIN operation only; once a digest is in the lock,
|
|
783
|
+
* installs go through `fetchManifestByDigest` and never come back here (C2.3).
|
|
784
|
+
*/
|
|
785
|
+
export async function resolveVersionToDigest({
|
|
786
|
+
registry,
|
|
787
|
+
repository,
|
|
788
|
+
version,
|
|
789
|
+
scheme = "https",
|
|
790
|
+
timeoutMs = 30000,
|
|
791
|
+
fetchImpl = fetch,
|
|
792
|
+
retryOptions = {},
|
|
793
|
+
}) {
|
|
794
|
+
const result = await lookupManifest({
|
|
795
|
+
registry,
|
|
796
|
+
repository,
|
|
797
|
+
reference: version,
|
|
798
|
+
scheme,
|
|
799
|
+
timeoutMs,
|
|
800
|
+
fetchImpl,
|
|
801
|
+
retryOptions,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
if (result.outcome === "present") return result.digest;
|
|
805
|
+
if (result.outcome === "absent") {
|
|
806
|
+
throw new OciRegistryError(
|
|
807
|
+
`${registry}/${repository}:${version} is not published`,
|
|
808
|
+
"absent"
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
throw new OciRegistryError(
|
|
812
|
+
`Refusing to resolve ${registry}/${repository}:${version}: ${result.reason}`,
|
|
813
|
+
"unverifiable"
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Fetch a manifest BY DIGEST and prove the bytes hash to the digest asked for.
|
|
819
|
+
*
|
|
820
|
+
* The self-check is not ceremony. A digest is a content address, so bytes that
|
|
821
|
+
* do not hash to it are not the object requested however the registry labelled
|
|
822
|
+
* them, and this is the one check that makes every later layer-digest check
|
|
823
|
+
* meaningful — the layer descriptors live inside these bytes.
|
|
824
|
+
*/
|
|
825
|
+
export async function fetchManifestByDigest({
|
|
826
|
+
registry,
|
|
827
|
+
repository,
|
|
828
|
+
digest,
|
|
829
|
+
scheme = "https",
|
|
830
|
+
timeoutMs = 30000,
|
|
831
|
+
fetchImpl = fetch,
|
|
832
|
+
retryOptions = {},
|
|
833
|
+
}) {
|
|
834
|
+
if (!isValidDigest(digest)) {
|
|
835
|
+
throw new OciRegistryError(`Invalid OCI manifest digest "${digest}"`, "invalid-reference");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let result;
|
|
839
|
+
try {
|
|
840
|
+
result = await registryGet({
|
|
841
|
+
registry,
|
|
842
|
+
repository,
|
|
843
|
+
path: `manifests/${digest}`,
|
|
844
|
+
accept: MANIFEST_ACCEPT,
|
|
845
|
+
scheme,
|
|
846
|
+
timeoutMs,
|
|
847
|
+
maxBytes: MAX_MANIFEST_BYTES,
|
|
848
|
+
fetchImpl,
|
|
849
|
+
retryOptions,
|
|
850
|
+
});
|
|
851
|
+
} catch (error) {
|
|
852
|
+
throw new OciRegistryError(
|
|
853
|
+
`Failed to fetch ${registry}/${repository}@${digest}: ${error.message}`,
|
|
854
|
+
"unverifiable"
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
if (result.tokenFailure) {
|
|
858
|
+
throw new OciRegistryError(
|
|
859
|
+
`Failed to fetch ${registry}/${repository}@${digest}: ${result.tokenFailure}`,
|
|
860
|
+
"unverifiable"
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const { response } = result;
|
|
865
|
+
if (response.status !== 200) {
|
|
866
|
+
const classification = classifyManifestResponse(response);
|
|
867
|
+
throw new OciRegistryError(
|
|
868
|
+
`Failed to fetch ${registry}/${repository}@${digest}: ${classification.reason ?? `HTTP ${response.status}`}`,
|
|
869
|
+
classification.outcome === "absent" ? "absent" : "unverifiable"
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const actual = sha256Digest(response.buffer);
|
|
874
|
+
if (actual !== digest.trim()) {
|
|
875
|
+
throw new OciRegistryError(
|
|
876
|
+
`${registry}/${repository}@${digest} returned bytes whose digest is ${actual}`,
|
|
877
|
+
"tampered"
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
let manifest;
|
|
882
|
+
try {
|
|
883
|
+
manifest = JSON.parse(response.buffer.toString("utf8"));
|
|
884
|
+
} catch (error) {
|
|
885
|
+
throw new OciRegistryError(
|
|
886
|
+
`${registry}/${repository}@${digest} is not a JSON manifest: ${error.message}`,
|
|
887
|
+
"tampered"
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return { manifest, bytes: response.buffer, digest: digest.trim() };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Fetch a blob and verify it against the digest that named it (C4.1).
|
|
896
|
+
*
|
|
897
|
+
* Verification happens HERE rather than in the caller so that no code path can
|
|
898
|
+
* obtain unverified blob bytes: the only way to get a blob out of this module
|
|
899
|
+
* is to have proved it already.
|
|
900
|
+
*/
|
|
901
|
+
export async function fetchBlob({
|
|
902
|
+
registry,
|
|
903
|
+
repository,
|
|
904
|
+
digest,
|
|
905
|
+
scheme = "https",
|
|
906
|
+
timeoutMs = 30000,
|
|
907
|
+
maxBytes = MAX_BLOB_BYTES,
|
|
908
|
+
fetchImpl = fetch,
|
|
909
|
+
retryOptions = {},
|
|
910
|
+
}) {
|
|
911
|
+
if (!isValidDigest(digest)) {
|
|
912
|
+
throw new OciRegistryError(`Invalid OCI blob digest "${digest}"`, "invalid-reference");
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
let result;
|
|
916
|
+
try {
|
|
917
|
+
result = await registryGet({
|
|
918
|
+
registry,
|
|
919
|
+
repository,
|
|
920
|
+
path: `blobs/${digest}`,
|
|
921
|
+
scheme,
|
|
922
|
+
timeoutMs,
|
|
923
|
+
maxBytes,
|
|
924
|
+
fetchImpl,
|
|
925
|
+
retryOptions,
|
|
926
|
+
});
|
|
927
|
+
} catch (error) {
|
|
928
|
+
throw new OciRegistryError(
|
|
929
|
+
`Failed to fetch blob ${digest} from ${registry}/${repository}: ${error.message}`,
|
|
930
|
+
"unverifiable"
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
if (result.tokenFailure) {
|
|
934
|
+
throw new OciRegistryError(
|
|
935
|
+
`Failed to fetch blob ${digest} from ${registry}/${repository}: ${result.tokenFailure}`,
|
|
936
|
+
"unverifiable"
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const { response } = result;
|
|
941
|
+
if (response.status !== 200) {
|
|
942
|
+
throw new OciRegistryError(
|
|
943
|
+
`Failed to fetch blob ${digest} from ${registry}/${repository}: HTTP ${response.status}`,
|
|
944
|
+
response.status === 401 || response.status === 403 ? "denied" : "unverifiable"
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const actual = sha256Digest(response.buffer);
|
|
949
|
+
if (actual !== digest.trim()) {
|
|
950
|
+
throw new OciRegistryError(
|
|
951
|
+
`Blob ${digest} from ${registry}/${repository} hashes to ${actual}`,
|
|
952
|
+
"tampered"
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return response.buffer;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Fetch the cosign signature manifest for a digest, if one is published.
|
|
961
|
+
*
|
|
962
|
+
* Returns null when the signature tag is genuinely absent, and throws on
|
|
963
|
+
* `unknown` — the same three-outcome rule, applied to the object whose absence
|
|
964
|
+
* would otherwise read as "this artifact is unsigned" (C2.2, C6.4).
|
|
965
|
+
*/
|
|
966
|
+
export async function fetchSignatureManifest({
|
|
967
|
+
registry,
|
|
968
|
+
repository,
|
|
969
|
+
digest,
|
|
970
|
+
scheme = "https",
|
|
971
|
+
timeoutMs = 30000,
|
|
972
|
+
fetchImpl = fetch,
|
|
973
|
+
retryOptions = {},
|
|
974
|
+
}) {
|
|
975
|
+
const tag = cosignSignatureTag(digest);
|
|
976
|
+
const result = await lookupManifest({
|
|
977
|
+
registry,
|
|
978
|
+
repository,
|
|
979
|
+
reference: tag,
|
|
980
|
+
scheme,
|
|
981
|
+
timeoutMs,
|
|
982
|
+
fetchImpl,
|
|
983
|
+
retryOptions,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
if (result.outcome === "absent") return null;
|
|
987
|
+
if (result.outcome === "unknown") {
|
|
988
|
+
throw new OciRegistryError(
|
|
989
|
+
`Could not determine whether ${registry}/${repository}:${tag} exists: ${result.reason}`,
|
|
990
|
+
"unverifiable"
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
try {
|
|
995
|
+
return { manifest: JSON.parse(result.body), digest: result.digest };
|
|
996
|
+
} catch (error) {
|
|
997
|
+
throw new OciRegistryError(
|
|
998
|
+
`The cosign signature manifest at ${registry}/${repository}:${tag} is not JSON: ${error.message}`,
|
|
999
|
+
"tampered"
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
}
|