@crewhaus/tool-containers 0.7.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/index.js ADDED
@@ -0,0 +1,475 @@
1
+ /**
2
+ * @crewhaus/tool-containers — what is actually behind this image tag.
3
+ *
4
+ * Two questions a harness asks constantly and a model cannot answer by
5
+ * reasoning: which digest does `ghcr.io/owner/app:1.4.2` resolve to right now,
6
+ * and what tags does that repository have. Both are answered over the OCI
7
+ * distribution API — no daemon, no `docker pull`, no layers downloaded, and
8
+ * nothing written to disk. A digest resolution costs one or two small GETs.
9
+ *
10
+ * THE DIGEST IS THE POINT, so it is computed here rather than believed:
11
+ * `sha256` over the exact response bytes, checked against the registry's
12
+ * `Docker-Content-Digest` header rather than copied from it, and checked
13
+ * against the digest the caller asked for (or the one an index promised) when
14
+ * there is one. Nothing on that path is ever parsed and re-serialized —
15
+ * re-serializing JSON changes key order, whitespace and escapes, all of which
16
+ * are inside the hash, so a digest derived from a round-tripped document is a
17
+ * different number that still looks plausible.
18
+ *
19
+ * Auth is the anonymous Bearer dance, driven off the registry's own
20
+ * `WWW-Authenticate` challenge: request, 401, token from the named realm,
21
+ * retry. Docker Hub, ghcr.io and quay.io differ in realm, service and scope
22
+ * echo, and all three work here without being named in the code. Private
23
+ * repositories do not: these tools hold no credentials, and say so.
24
+ *
25
+ * Every request goes through `@crewhaus/tool-fetch`'s SSRF guard with the
26
+ * connection pinned to the IP it validated, re-checked at every redirect hop.
27
+ * A registry that answers with `302 Location: http://169.254.169.254/…` is the
28
+ * attack, and a bare `fetch` would have followed it.
29
+ */
30
+ import { buildTool } from "@crewhaus/tool-builder";
31
+ import { z } from "zod";
32
+ import { MANIFEST_ACCEPT, ManifestError, blobPath, classifyDocument, manifestPath, parseDocument, parsePlatform, readConfig, readIndex, readManifest, selectPlatform, verifyDigest, } from "./lib/manifest";
33
+ import { parseDigest, parseImageRef, pullScope } from "./lib/ref";
34
+ import { newClient, registryGet, registryGetUrl, registryUrl, } from "./lib/registry";
35
+ import { filterTags, nextPageUrl, readTagsPage, sortTags } from "./lib/tags";
36
+ export { _setFetch } from "./lib/http";
37
+ export { RegistryError } from "./lib/registry";
38
+ export { ImageRefError } from "./lib/ref";
39
+ export { ManifestError };
40
+ const json = (value) => JSON.stringify(value);
41
+ /**
42
+ * Ceilings. A manifest is kilobytes and a config blob is tens of kilobytes;
43
+ * these are generous by two orders of magnitude and exist so a hostile or
44
+ * broken registry cannot stream until memory runs out.
45
+ */
46
+ const LIMITS = {
47
+ manifestBytes: 8 * 1024 * 1024,
48
+ configBytes: 8 * 1024 * 1024,
49
+ tagsBytes: 16 * 1024 * 1024,
50
+ /**
51
+ * `withDigests` costs one manifest GET per tag. Past this many the call stops
52
+ * being a lookup and becomes a crawl of somebody's registry, so it is refused
53
+ * with an instruction rather than quietly truncated.
54
+ */
55
+ digestFanout: 50,
56
+ };
57
+ /** Config media types that are an IMAGE config, i.e. worth parsing as one. */
58
+ const IMAGE_CONFIG_TYPES = new Set([
59
+ "application/vnd.oci.image.config.v1+json",
60
+ "application/vnd.docker.container.image.v1+json",
61
+ ]);
62
+ const DEFAULT_PLATFORM = "linux/amd64";
63
+ const timeoutField = z
64
+ .number()
65
+ .int()
66
+ .min(1_000)
67
+ .max(120_000)
68
+ .optional()
69
+ .describe("per-request timeout in milliseconds; default 20000");
70
+ function clientFor(ref, ctx, timeoutMs) {
71
+ return newClient(ref.apiHost, ctx?.signal, timeoutMs ?? 20_000);
72
+ }
73
+ /**
74
+ * Fetch one manifest and establish its digest.
75
+ *
76
+ * The order of the three steps below is the contract: read the bytes, hash the
77
+ * bytes, and only then parse them. `expected` is the digest this document was
78
+ * promised to have — the caller's `@sha256:…`, or the descriptor inside an
79
+ * index — and a mismatch aborts rather than being reported as a field, because
80
+ * the bytes in hand are then not the document that was addressed.
81
+ */
82
+ async function fetchManifest(client, ref, reference, expected, what) {
83
+ const res = await registryGet(client, manifestPath(ref, reference), {
84
+ accept: MANIFEST_ACCEPT,
85
+ maxBytes: LIMITS.manifestBytes,
86
+ scope: pullScope(ref.repository),
87
+ });
88
+ const verdict = verifyDigest(res.bytes, res.headers.get("docker-content-digest"), expected, what);
89
+ const document = parseDocument(res.bytes, what);
90
+ const { kind, mediaType } = classifyDocument(res.headers.get("content-type"), document);
91
+ return {
92
+ digest: verdict.digest,
93
+ headerDigest: verdict.headerDigest,
94
+ headerAgreed: verdict.headerAgreed,
95
+ mediaType,
96
+ kind,
97
+ size: res.bytes.byteLength,
98
+ document,
99
+ };
100
+ }
101
+ /**
102
+ * Fetch a config blob and verify it against the descriptor that named it. A
103
+ * blob is content addressed too, so the same rule applies: the digest of the
104
+ * bytes must be the digest the manifest promised, or the labels and entrypoint
105
+ * we are about to report belong to some other image.
106
+ */
107
+ async function fetchConfig(client, ref, digest, mediaType) {
108
+ const res = await registryGet(client, blobPath(ref, digest), {
109
+ accept: mediaType === "" ? "application/json" : mediaType,
110
+ maxBytes: LIMITS.configBytes,
111
+ scope: pullScope(ref.repository),
112
+ });
113
+ verifyDigest(res.bytes, res.headers.get("docker-content-digest"), digest, `config blob ${digest}`);
114
+ return readConfig(parseDocument(res.bytes, "config blob"));
115
+ }
116
+ export const containerImageInspect = buildTool({
117
+ name: "ContainerImageInspect",
118
+ description: "Resolve a container image tag to the digest it points at right now, over the OCI registry API — no daemon, no pull, no Docker. Returns the manifest digest and media type, the per-platform digest when the tag is a multi-arch index, the layer sizes, and the image config's labels, entrypoint and created time. Use it to pin a deployment to a digest, or to read an image's labels without downloading it. Pass compareTo a digest you recorded earlier and the answer says, as a boolean, whether the tag still points at it. The digest is computed from the response bytes and checked against the registry's own header rather than copied from it, so a registry cannot tell this tool that bytes are something they are not. Anonymous pulls only: a private repository is refused, with an explanation, rather than guessed at.",
119
+ inputSchema: z.object({
120
+ image: z
121
+ .string()
122
+ .min(1)
123
+ .describe("image reference, e.g. alpine:3.19, ghcr.io/owner/app:1.4.2, quay.io/org/app@sha256:…"),
124
+ platform: z
125
+ .string()
126
+ .optional()
127
+ .describe(`which platform to resolve out of a multi-arch index, as os/arch[/variant]; default ${DEFAULT_PLATFORM}`),
128
+ indexOnly: z
129
+ .boolean()
130
+ .optional()
131
+ .describe("stop at the index and list its platforms instead of resolving one of them"),
132
+ includeConfig: z
133
+ .boolean()
134
+ .optional()
135
+ .describe("fetch the image config blob for labels, entrypoint and created time; on by default, costs one extra GET"),
136
+ includeLayers: z
137
+ .boolean()
138
+ .optional()
139
+ .describe("list every layer, not just the count and total size"),
140
+ compareTo: z
141
+ .string()
142
+ .optional()
143
+ .describe("a digest (sha256:…) you pinned earlier, or another image reference; the answer says whether it is the same image — use it to find out whether a tag has moved"),
144
+ timeoutMs: timeoutField,
145
+ }),
146
+ readOnly: true,
147
+ concurrencySafe: true,
148
+ // Reaching a registry is a network egress like any other: the payload is a
149
+ // repository name, which is itself worth classifying before it leaves.
150
+ scope: "external",
151
+ ioCapability: "network",
152
+ execute: async (input, ctx) => {
153
+ const ref = parseImageRef(input.image);
154
+ const client = clientFor(ref, ctx, input.timeoutMs);
155
+ const wantConfig = input.includeConfig ?? true;
156
+ const top = await fetchManifest(client, ref, ref.reference, ref.referenceKind === "digest" ? ref.reference : null, `manifest for ${ref.canonical}`);
157
+ // `image` echoes the reference; `resolved` is what the tag points at today;
158
+ // `manifest` (below) is the single-platform image inside it. Keeping the
159
+ // three apart is what lets a caller pin `resolved.digest` for a multi-arch
160
+ // deployment and `manifest.digest` for a single-arch node.
161
+ const head = {
162
+ image: ref.canonical,
163
+ registry: ref.registry,
164
+ repository: ref.repository,
165
+ reference: ref.reference,
166
+ referenceKind: ref.referenceKind,
167
+ resolved: {
168
+ digest: top.digest,
169
+ mediaType: top.mediaType,
170
+ kind: top.kind,
171
+ size: top.size,
172
+ // "The header agreed with the bytes" and "there was no header" are
173
+ // different facts; a caller auditing a registry wants to tell them apart.
174
+ digestHeader: top.headerDigest,
175
+ digestHeaderAgreed: top.headerAgreed,
176
+ },
177
+ };
178
+ if (top.kind === "manifest") {
179
+ const manifest = await describeImage(client, ref, top, undefined, wantConfig, input.includeLayers ?? false);
180
+ const comparison = await compare(input.compareTo, top.digest, manifest["digest"], ctx, input.timeoutMs);
181
+ return json({
182
+ ...head,
183
+ manifest,
184
+ ...(comparison !== undefined ? { comparison } : {}),
185
+ requests: client.requests,
186
+ });
187
+ }
188
+ const { platforms, attestations } = readIndex(top.document);
189
+ const platformList = platforms.map((p) => ({
190
+ platform: p.platform,
191
+ digest: p.digest,
192
+ size: p.size,
193
+ mediaType: p.mediaType,
194
+ ...(p.osVersion !== undefined ? { osVersion: p.osVersion } : {}),
195
+ }));
196
+ if (input.indexOnly === true) {
197
+ const comparison = await compare(input.compareTo, top.digest, undefined, ctx, input.timeoutMs);
198
+ return json({
199
+ ...head,
200
+ platforms: platformList,
201
+ attestations,
202
+ ...(comparison !== undefined ? { comparison } : {}),
203
+ requests: client.requests,
204
+ });
205
+ }
206
+ const wanted = parsePlatform(input.platform ?? DEFAULT_PLATFORM);
207
+ const selected = selectOrExplain(platforms, wanted, input.platform ?? DEFAULT_PLATFORM, ref);
208
+ // `windows/amd64` is not one image: an index carries one entry per Windows
209
+ // build, distinguished only by `os.version`. Picking the first and calling
210
+ // it "the amd64 image" is a digest that will not run on the caller's host,
211
+ // so the count travels with the answer.
212
+ const siblings = platforms.filter((p) => p.platform === selected.platform).length;
213
+ const child = await fetchManifest(client, ref, selected.digest, selected.digest, `manifest ${selected.digest} for ${selected.platform}`);
214
+ if (child.kind !== "manifest") {
215
+ throw new Error(`${ref.canonical} is an index whose ${selected.platform} entry is another index (${child.mediaType}) — nested indexes are not resolved, since there is no single per-platform image to report`);
216
+ }
217
+ const manifest = await describeImage(client, ref, child, selected, wantConfig, input.includeLayers ?? false, siblings);
218
+ const comparison = await compare(input.compareTo, top.digest, manifest["digest"], ctx, input.timeoutMs);
219
+ return json({
220
+ ...head,
221
+ platforms: platformList,
222
+ attestations,
223
+ manifest,
224
+ ...(comparison !== undefined ? { comparison } : {}),
225
+ requests: client.requests,
226
+ });
227
+ },
228
+ });
229
+ /**
230
+ * Answer "is this still the image I pinned?" with a boolean rather than two long
231
+ * hex strings for somebody else to diff. Comparing DIGESTS is the only version
232
+ * of this question worth asking: two references can carry the same tag and
233
+ * different content, which is the entire failure mode.
234
+ *
235
+ * A digest costs nothing — it is compared against both the digest the tag
236
+ * resolves to and the per-platform digest inside it, since a caller may have
237
+ * pinned either. A reference costs one lookup of its own and may live on a
238
+ * different registry: a digest means the same thing everywhere.
239
+ */
240
+ async function compare(compareTo, resolvedDigest, platformDigest, ctx, timeoutMs) {
241
+ if (compareTo === undefined || compareTo.trim() === "")
242
+ return undefined;
243
+ const other = compareTo.trim();
244
+ let otherDigest;
245
+ let otherLabel = other;
246
+ // The discriminator is the ALGORITHM, not the shape. `<word>:<hex>` describes
247
+ // a digest AND describes `alpine:3`, `nginx:1`, `redis:7` — half of Docker
248
+ // Hub — so a shape test would refuse the "or another image reference" this
249
+ // input documents. Keying on the algorithm also keeps `sha256:deadbeef` a
250
+ // bad digest rather than a lookup of docker.io/library/sha256:deadbeef.
251
+ if (/^(?:sha256|sha512):/.test(other)) {
252
+ const parsed = parseDigest(other);
253
+ otherDigest = `${parsed.algorithm}:${parsed.hex}`;
254
+ }
255
+ else {
256
+ const otherRef = parseImageRef(other);
257
+ otherLabel = otherRef.canonical;
258
+ const otherClient = clientFor(otherRef, ctx, timeoutMs);
259
+ const fetched = await fetchManifest(otherClient, otherRef, otherRef.reference, otherRef.referenceKind === "digest" ? otherRef.reference : null, `manifest for ${otherRef.canonical}`);
260
+ otherDigest = fetched.digest;
261
+ }
262
+ const matched = otherDigest === resolvedDigest
263
+ ? "resolved"
264
+ : platformDigest !== undefined && otherDigest === platformDigest
265
+ ? "platform"
266
+ : null;
267
+ return { to: otherLabel, digest: otherDigest, same: matched !== null, matched };
268
+ }
269
+ /**
270
+ * Refuse a platform miss with the list of platforms that DO exist. "no match"
271
+ * alone sends the caller back to the registry to ask a question this call
272
+ * already has the answer to.
273
+ */
274
+ function selectOrExplain(platforms, wanted, asked, ref) {
275
+ // The choice itself is `selectPlatform`, not a second copy of the rule here:
276
+ // a caller asking for `linux/arm64` wants the image a plain arm64 host would
277
+ // pull, which is the variant-LESS entry when the index has one — not
278
+ // whichever arm64 entry buildx happened to write first.
279
+ const selected = selectPlatform(platforms, wanted);
280
+ if (selected !== undefined)
281
+ return selected;
282
+ const available = platforms.map((p) => p.platform).join(", ") || "none";
283
+ throw new Error(`${ref.canonical} has no ${asked} image — the index lists ${available}`);
284
+ }
285
+ async function describeImage(client, ref, manifest, selected, wantConfig, wantLayers,
286
+ /** How many index entries share the selected platform string; 1 unless os.version splits them. */
287
+ platformMatches = 1) {
288
+ const summary = readManifest(manifest.document);
289
+ const isImageConfig = IMAGE_CONFIG_TYPES.has(summary.config.mediaType);
290
+ let config;
291
+ let configNote;
292
+ if (wantConfig) {
293
+ if (isImageConfig) {
294
+ config = await fetchConfig(client, ref, summary.config.digest, summary.config.mediaType);
295
+ }
296
+ else {
297
+ // Helm charts, cosign signatures and SBOM artifacts all ride the image
298
+ // manifest shape with a config blob that is not an image config. Fetching
299
+ // and parsing it as one would invent fields nobody set.
300
+ configNote = `config media type is ${summary.config.mediaType || "(unset)"}, not an image config — this is an OCI artifact rather than a runnable image, so no config was read`;
301
+ }
302
+ }
303
+ return {
304
+ digest: manifest.digest,
305
+ mediaType: manifest.mediaType,
306
+ size: manifest.size,
307
+ ...(selected !== undefined ? { platform: selected.platform } : {}),
308
+ ...(selected?.osVersion !== undefined ? { osVersion: selected.osVersion } : {}),
309
+ ...(platformMatches > 1 ? { platformMatches } : {}),
310
+ configDigest: summary.config.digest,
311
+ configMediaType: summary.config.mediaType,
312
+ ...(isImageConfig ? {} : { artifact: true }),
313
+ layerCount: summary.layers.length,
314
+ totalLayerSize: summary.totalLayerSize,
315
+ // A missing `size` makes the total a floor. Saying so beats a plausible
316
+ // number that is quietly short by a layer.
317
+ ...(summary.unsizedLayers > 0 ? { unsizedLayers: summary.unsizedLayers } : {}),
318
+ ...(wantLayers
319
+ ? {
320
+ layers: summary.layers.map((l) => ({
321
+ digest: l.digest,
322
+ size: l.size,
323
+ mediaType: l.mediaType,
324
+ })),
325
+ }
326
+ : {}),
327
+ ...(summary.annotations !== undefined ? { annotations: summary.annotations } : {}),
328
+ ...(summary.subject !== undefined ? { subject: summary.subject } : {}),
329
+ ...(config !== undefined ? { config } : {}),
330
+ ...(configNote !== undefined ? { configNote } : {}),
331
+ };
332
+ }
333
+ export const containerImageTags = buildTool({
334
+ name: "ContainerImageTags",
335
+ description: "List a container repository's tags over the OCI registry API, paged, filtered by glob and sorted newest-first by semver precedence — with tags that are not versions (latest, edge, sha-9f3c1a) kept and listed after them rather than dropped or guessed at. Use it to find the newest release of an image, to check whether a version was ever published, or to see what a repository offers before pinning to one. Set withDigests to resolve each listed tag to its digest, which costs one request per tag and is capped. Anonymous pulls only; a private repository is refused rather than reported as empty.",
336
+ inputSchema: z.object({
337
+ image: z
338
+ .string()
339
+ .min(1)
340
+ .describe("repository reference, e.g. alpine, ghcr.io/owner/app; a tag in the reference is reported and ignored"),
341
+ match: z
342
+ .string()
343
+ .optional()
344
+ .describe("glob over tag names, e.g. '1.*' or 'v?.?.?'; * and ? only"),
345
+ sort: z
346
+ .enum(["semver", "name", "registry"])
347
+ .optional()
348
+ .describe("semver (default, newest first), name (lexicographic), or registry (as returned)"),
349
+ limit: z
350
+ .number()
351
+ .int()
352
+ .min(1)
353
+ .max(1000)
354
+ .optional()
355
+ .describe("how many tags to return; default 100"),
356
+ pageSize: z
357
+ .number()
358
+ .int()
359
+ .min(1)
360
+ .max(1000)
361
+ .optional()
362
+ .describe("tags per registry request; default 100"),
363
+ maxPages: z
364
+ .number()
365
+ .int()
366
+ .min(1)
367
+ .max(20)
368
+ .optional()
369
+ .describe("how many pages to follow; default 5"),
370
+ withDigests: z
371
+ .boolean()
372
+ .optional()
373
+ .describe(`resolve each returned tag to its digest; one request per tag, at most ${LIMITS.digestFanout}`),
374
+ timeoutMs: timeoutField,
375
+ }),
376
+ readOnly: true,
377
+ concurrencySafe: true,
378
+ scope: "external",
379
+ ioCapability: "network",
380
+ execute: async (input, ctx) => {
381
+ const ref = parseImageRef(input.image, { defaultTag: false });
382
+ const client = clientFor(ref, ctx, input.timeoutMs);
383
+ const limit = input.limit ?? 100;
384
+ const pageSize = input.pageSize ?? 100;
385
+ const maxPages = input.maxPages ?? 5;
386
+ const scope = pullScope(ref.repository);
387
+ let url = registryUrl(ref.apiHost, `/v2/${ref.repository}/tags/list?n=${pageSize}`);
388
+ const collected = [];
389
+ let pages = 0;
390
+ let morePages = false;
391
+ let malformed = 0;
392
+ while (url !== null) {
393
+ if (pages >= maxPages) {
394
+ morePages = true;
395
+ break;
396
+ }
397
+ const res = await registryGetUrl(client, url, {
398
+ accept: "application/json",
399
+ maxBytes: LIMITS.tagsBytes,
400
+ scope,
401
+ });
402
+ pages++;
403
+ // Nothing here is content addressed, so parsing is safe — unlike the
404
+ // manifest path, where the bytes ARE the identity.
405
+ const page = readTagsPage(parseDocument(res.bytes, "tags/list response"));
406
+ collected.push(...page.tags);
407
+ malformed += page.malformed;
408
+ url = nextPageUrl(res.headers.get("link"), url);
409
+ }
410
+ const matched = filterTags(collected, input.match);
411
+ const sorted = sortTags(matched, input.sort ?? "semver");
412
+ const selected = sorted.slice(0, limit);
413
+ const head = {
414
+ registry: ref.registry,
415
+ repository: ref.repository,
416
+ ...(ref.reference !== "" ? { ignoredReference: ref.reference } : {}),
417
+ fetched: collected.length,
418
+ // Entries the registry listed that are not tags under the distribution
419
+ // grammar. They cannot be pulled and are not put in a URL, but a caller
420
+ // auditing a registry should hear that they were sent.
421
+ ...(malformed > 0 ? { malformed } : {}),
422
+ matched: matched.length,
423
+ returned: selected.length,
424
+ pages,
425
+ morePages,
426
+ sort: input.sort ?? "semver",
427
+ };
428
+ if (input.withDigests !== true) {
429
+ return json({ ...head, tags: selected, requests: client.requests });
430
+ }
431
+ if (selected.length > LIMITS.digestFanout) {
432
+ throw new Error(`withDigests resolves one manifest per tag and ${selected.length} tags were selected, over the ${LIMITS.digestFanout} cap — narrow with match, or lower limit`);
433
+ }
434
+ const tags = [];
435
+ for (const tag of selected) {
436
+ try {
437
+ const manifest = await fetchManifest(client, ref, tag, null, `manifest for ${ref.registry}/${ref.repository}:${tag}`);
438
+ tags.push({
439
+ tag,
440
+ digest: manifest.digest,
441
+ mediaType: manifest.mediaType,
442
+ kind: manifest.kind,
443
+ size: manifest.size,
444
+ });
445
+ }
446
+ catch (err) {
447
+ // A tag that 404s (deleted between the listing and now) or that is an
448
+ // unreadable schema 1 manifest is reported against that tag; the rest of
449
+ // the list is still worth having. An INTEGRITY failure is different and
450
+ // is never swallowed — see the rethrow below.
451
+ if (isIntegrityFailure(err))
452
+ throw err;
453
+ tags.push({ tag, error: err.message });
454
+ }
455
+ }
456
+ return json({ ...head, tags, requests: client.requests });
457
+ },
458
+ });
459
+ /**
460
+ * A digest that does not match its content is evidence about the registry, not
461
+ * about one tag, so it aborts the whole listing. Everything else — 404, 429, a
462
+ * schema 1 manifest — is a fact about that tag alone.
463
+ *
464
+ * The flag is set where the mismatch is detected. Matching on message text
465
+ * here would mean a reworded sentence silently downgrades a tampering signal
466
+ * to a footnote against one tag.
467
+ */
468
+ function isIntegrityFailure(err) {
469
+ return err instanceof ManifestError && err.integrity;
470
+ }
471
+ /** Every tool this package registers, in the order a catalog should list them. */
472
+ export const CONTAINER_TOOLS = Object.freeze([
473
+ containerImageInspect,
474
+ containerImageTags,
475
+ ]);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The anonymous auth dance, parsed generically.
3
+ *
4
+ * A registry answers an unauthenticated manifest request with 401 and a
5
+ * `WWW-Authenticate: Bearer realm="…",service="…",scope="…"` challenge. The
6
+ * client fetches a token from the realm and retries. Docker Hub, ghcr.io and
7
+ * quay.io all differ in the realm host, the service name and whether they echo
8
+ * a scope — which is precisely why this is driven off the challenge instead of
9
+ * a table of registries. A registry we have never heard of works for free; a
10
+ * table would have to be edited every time one appeared.
11
+ *
12
+ * The challenge is data from the server, so it is treated as hostile:
13
+ * the realm must be an absolute https URL with no credentials in it, and the
14
+ * SCOPE WE ASK FOR IS OUR OWN — a challenge that asks us to request
15
+ * `repository:someone/else:push` gets our `repository:<what we asked for>:pull`
16
+ * instead.
17
+ */
18
+ export type AuthChallenge = {
19
+ readonly scheme: string;
20
+ readonly params: Readonly<Record<string, string>>;
21
+ };
22
+ export declare class AuthChallengeError extends Error {
23
+ readonly name = "AuthChallengeError";
24
+ }
25
+ /**
26
+ * Parse a `WWW-Authenticate` header into its challenges.
27
+ *
28
+ * The grammar is genuinely ambiguous — `,` separates auth-params AND
29
+ * challenges — so this resolves it the way every HTTP client does: a bare
30
+ * token that is NOT followed by `=` starts the next challenge. That is what
31
+ * makes `Basic realm="x", Bearer realm="y"` (quay.io has shipped this shape)
32
+ * parse as two challenges rather than one Basic with a junk param.
33
+ */
34
+ export declare function parseAuthenticateHeader(header: string): AuthChallenge[];
35
+ /** The Bearer challenge, if the server offered one. Scheme match is case-insensitive. */
36
+ export declare function bearerChallenge(challenges: readonly AuthChallenge[]): AuthChallenge | undefined;
37
+ /**
38
+ * Build the token request URL from a challenge.
39
+ *
40
+ * `scope` is ours, not the challenge's. `service` is taken from the challenge
41
+ * because registries genuinely differ (`registry.docker.io` vs `ghcr.io`) and
42
+ * some reject a token request without it — but it is only ever a query
43
+ * parameter, so the worst a hostile value can do is get its own token refused.
44
+ */
45
+ export declare function tokenRequestUrl(challenge: AuthChallenge, scope: string): URL;
46
+ /**
47
+ * Pull the bearer token out of a token endpoint's JSON body. Registries are
48
+ * inconsistent: the distribution spec says `token`, OAuth2 says `access_token`,
49
+ * and ghcr.io returns both. Parsing here is safe — a token is not content
50
+ * addressed, so nothing downstream hashes these bytes.
51
+ */
52
+ export declare function readTokenBody(text: string): string;