@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.
@@ -0,0 +1,174 @@
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 class AuthChallengeError extends Error {
19
+ name = "AuthChallengeError";
20
+ }
21
+ /** RFC 7230 token characters — what an unquoted scheme or auth-param name is made of. */
22
+ const TOKEN_CHARS = /[A-Za-z0-9!#$%&'*+\-.^_`|~]/;
23
+ /**
24
+ * Parse a `WWW-Authenticate` header into its challenges.
25
+ *
26
+ * The grammar is genuinely ambiguous — `,` separates auth-params AND
27
+ * challenges — so this resolves it the way every HTTP client does: a bare
28
+ * token that is NOT followed by `=` starts the next challenge. That is what
29
+ * makes `Basic realm="x", Bearer realm="y"` (quay.io has shipped this shape)
30
+ * parse as two challenges rather than one Basic with a junk param.
31
+ */
32
+ export function parseAuthenticateHeader(header) {
33
+ const challenges = [];
34
+ let i = 0;
35
+ const n = header.length;
36
+ const skipSeparators = () => {
37
+ while (i < n && (header[i] === " " || header[i] === "\t" || header[i] === ","))
38
+ i++;
39
+ };
40
+ const readToken = () => {
41
+ const start = i;
42
+ while (i < n && TOKEN_CHARS.test(header[i]))
43
+ i++;
44
+ return header.slice(start, i);
45
+ };
46
+ const skipSpace = () => {
47
+ while (i < n && (header[i] === " " || header[i] === "\t"))
48
+ i++;
49
+ };
50
+ const readValue = () => {
51
+ if (header[i] === '"') {
52
+ i++;
53
+ let out = "";
54
+ while (i < n) {
55
+ const ch = header[i];
56
+ // A quoted-pair escapes the next character verbatim; without this a
57
+ // realm containing an escaped quote truncates the URL.
58
+ if (ch === "\\" && i + 1 < n) {
59
+ out += header[i + 1];
60
+ i += 2;
61
+ continue;
62
+ }
63
+ if (ch === '"') {
64
+ i++;
65
+ return out;
66
+ }
67
+ out += ch;
68
+ i++;
69
+ }
70
+ throw new AuthChallengeError(`unterminated quoted value in WWW-Authenticate: ${header}`);
71
+ }
72
+ return readToken();
73
+ };
74
+ while (i < n) {
75
+ skipSeparators();
76
+ if (i >= n)
77
+ break;
78
+ const scheme = readToken();
79
+ if (scheme === "") {
80
+ // Nothing token-shaped here: the header is malformed past this point.
81
+ break;
82
+ }
83
+ const params = {};
84
+ while (true) {
85
+ const save = i;
86
+ skipSeparators();
87
+ if (i >= n)
88
+ break;
89
+ const name = readToken();
90
+ if (name === "") {
91
+ i = save;
92
+ break;
93
+ }
94
+ skipSpace();
95
+ if (header[i] !== "=") {
96
+ // A bare token — this is the next challenge's scheme, not our param.
97
+ i = save;
98
+ break;
99
+ }
100
+ i++; // consume "="
101
+ skipSpace();
102
+ params[name.toLowerCase()] = readValue();
103
+ }
104
+ challenges.push({ scheme, params });
105
+ // Guard against a pathological header pinning the loop at one position.
106
+ const before = i;
107
+ skipSeparators();
108
+ if (i === before && i < n && !TOKEN_CHARS.test(header[i]))
109
+ i++;
110
+ }
111
+ return challenges;
112
+ }
113
+ /** The Bearer challenge, if the server offered one. Scheme match is case-insensitive. */
114
+ export function bearerChallenge(challenges) {
115
+ return challenges.find((c) => c.scheme.toLowerCase() === "bearer");
116
+ }
117
+ /**
118
+ * Build the token request URL from a challenge.
119
+ *
120
+ * `scope` is ours, not the challenge's. `service` is taken from the challenge
121
+ * because registries genuinely differ (`registry.docker.io` vs `ghcr.io`) and
122
+ * some reject a token request without it — but it is only ever a query
123
+ * parameter, so the worst a hostile value can do is get its own token refused.
124
+ */
125
+ export function tokenRequestUrl(challenge, scope) {
126
+ const realm = challenge.params["realm"];
127
+ if (realm === undefined || realm === "") {
128
+ throw new AuthChallengeError("registry asked for Bearer auth but the challenge has no realm — nothing to fetch a token from");
129
+ }
130
+ let url;
131
+ try {
132
+ url = new URL(realm);
133
+ }
134
+ catch {
135
+ throw new AuthChallengeError(`auth realm "${realm}" is not an absolute URL`);
136
+ }
137
+ if (url.protocol !== "https:") {
138
+ // A token fetched over plaintext can be swapped by anyone on the path, and
139
+ // a `file:`/`gopher:` realm is an outright attack on the client.
140
+ throw new AuthChallengeError(`auth realm "${realm}" is not https — refusing to fetch a token over ${url.protocol.replace(":", "")}`);
141
+ }
142
+ if (url.username !== "" || url.password !== "") {
143
+ throw new AuthChallengeError(`auth realm "${realm}" carries credentials — refusing; these tools are anonymous-only`);
144
+ }
145
+ const service = challenge.params["service"];
146
+ if (service !== undefined && service !== "")
147
+ url.searchParams.set("service", service);
148
+ url.searchParams.set("scope", scope);
149
+ return url;
150
+ }
151
+ /**
152
+ * Pull the bearer token out of a token endpoint's JSON body. Registries are
153
+ * inconsistent: the distribution spec says `token`, OAuth2 says `access_token`,
154
+ * and ghcr.io returns both. Parsing here is safe — a token is not content
155
+ * addressed, so nothing downstream hashes these bytes.
156
+ */
157
+ export function readTokenBody(text) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(text);
161
+ }
162
+ catch {
163
+ throw new AuthChallengeError("token endpoint did not return JSON");
164
+ }
165
+ if (typeof parsed !== "object" || parsed === null) {
166
+ throw new AuthChallengeError("token endpoint returned JSON that is not an object");
167
+ }
168
+ const body = parsed;
169
+ const token = body["token"] ?? body["access_token"];
170
+ if (typeof token !== "string" || token === "") {
171
+ throw new AuthChallengeError("token endpoint returned no token — the repository is probably private, and these tools are anonymous-only");
172
+ }
173
+ return token;
174
+ }
@@ -0,0 +1,48 @@
1
+ export declare class RegistryHttpError extends Error {
2
+ readonly name = "RegistryHttpError";
3
+ }
4
+ export type RawResponse = {
5
+ readonly status: number;
6
+ readonly headers: Headers;
7
+ /** The response body exactly as it arrived. Never decoded, never re-encoded. */
8
+ readonly bytes: Uint8Array;
9
+ /** The URL the body actually came from, after redirects. */
10
+ readonly url: string;
11
+ };
12
+ /**
13
+ * Fetch injection point. Tests replace it so the suite never opens a socket;
14
+ * production leaves it at `pinnedFetch`, which dials the exact IP the SSRF
15
+ * check validated.
16
+ */
17
+ export type RegistryFetch = (req: Request, pinnedIp: string) => Promise<Response>;
18
+ /** Test seam — `_setFetch(undefined)` restores the production dialer. */
19
+ export declare function _setFetch(fn: RegistryFetch | undefined): void;
20
+ export type GetOptions = {
21
+ readonly accept?: string;
22
+ readonly authorization?: string;
23
+ readonly maxBytes: number;
24
+ readonly signal?: AbortSignal;
25
+ /** `HEAD` is never used here: a body is the only thing we can verify. */
26
+ readonly method?: "GET";
27
+ };
28
+ /**
29
+ * GET a URL, following redirects by hand.
30
+ *
31
+ * Two rules the default `fetch` would not enforce for us:
32
+ * - every hop is re-checked against the SSRF guard and pinned to the IP it
33
+ * validated, because only the first URL is ours — the rest come from the
34
+ * server;
35
+ * - the `Authorization` header is DROPPED the moment a redirect leaves the
36
+ * origin it was minted for. Registries redirect to CDNs and object stores;
37
+ * forwarding a pull token to `random-bucket.example.com` hands it away.
38
+ */
39
+ export declare function httpGet(url: URL, options: GetOptions): Promise<RawResponse>;
40
+ /**
41
+ * A signal that aborts on the caller's cancellation OR after `timeoutMs`,
42
+ * whichever comes first. The returned `dispose` must run in a `finally` so a
43
+ * fast call does not leave a timer holding the process open.
44
+ */
45
+ export declare function deadlineSignal(outer: AbortSignal | undefined, timeoutMs: number): {
46
+ signal: AbortSignal;
47
+ dispose: () => void;
48
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The HTTP floor these tools stand on: SSRF-guarded, redirect-aware, and
3
+ * BYTE-PRESERVING.
4
+ *
5
+ * Byte preservation is the whole reason this is not `globalThis.fetch(...)
6
+ * .then(r => r.json())`. A manifest's digest is sha256 over the exact bytes the
7
+ * registry sent; decoding to a string and re-encoding is lossless only for
8
+ * well-formed UTF-8, and `JSON.parse` + `JSON.stringify` is not lossless at
9
+ * all. Everything here hands the caller a `Uint8Array` and lets the caller
10
+ * decide when it is safe to parse.
11
+ *
12
+ * The SSRF guard is `@crewhaus/tool-fetch`'s `assertNotSsrf` — the same
13
+ * checker the Fetch tool uses, including the DNS-rebinding backstop and the
14
+ * pinned IP it returns. A registry that answers a manifest request with
15
+ * `302 Location: http://169.254.169.254/latest/meta-data/` is the attack this
16
+ * closes, and it closes it at EVERY hop, not just the first.
17
+ */
18
+ import { assertNotSsrf } from "@crewhaus/tool-fetch";
19
+ export class RegistryHttpError extends Error {
20
+ name = "RegistryHttpError";
21
+ }
22
+ /** Registries redirect blobs to object storage; five hops is already generous. */
23
+ const MAX_REDIRECTS = 5;
24
+ /**
25
+ * Dial `pinnedIp` while keeping the real hostname for the Host header and TLS
26
+ * SNI, so certificate validation and virtual-host routing still work. Mirrors
27
+ * `@crewhaus/tool-fetch`'s own dialer, which is module-private there: resolving
28
+ * in the guard and letting `fetch` re-resolve at connect time is a rebinding
29
+ * TOCTOU, so the pin has to be applied by whoever makes the request.
30
+ */
31
+ function pinnedFetch(req, pinnedIp) {
32
+ const original = new URL(req.url);
33
+ const host = original.hostname;
34
+ const unbracketed = host.replace(/^\[/, "").replace(/\]$/, "");
35
+ if (pinnedIp === "" || unbracketed === pinnedIp)
36
+ return globalThis.fetch(req);
37
+ const pinnedUrl = new URL(original.toString());
38
+ pinnedUrl.hostname = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp;
39
+ const headers = new Headers(req.headers);
40
+ headers.set("host", original.port === "" ? host : `${host}:${original.port}`);
41
+ const init = {
42
+ method: req.method,
43
+ headers,
44
+ redirect: "manual",
45
+ signal: req.signal,
46
+ tls: { serverName: host },
47
+ };
48
+ return globalThis.fetch(pinnedUrl.toString(), init);
49
+ }
50
+ let rawFetch = pinnedFetch;
51
+ /** Test seam — `_setFetch(undefined)` restores the production dialer. */
52
+ export function _setFetch(fn) {
53
+ rawFetch = fn ?? pinnedFetch;
54
+ }
55
+ const USER_AGENT = "crewhaus-tool-containers/0.6";
56
+ /**
57
+ * GET a URL, following redirects by hand.
58
+ *
59
+ * Two rules the default `fetch` would not enforce for us:
60
+ * - every hop is re-checked against the SSRF guard and pinned to the IP it
61
+ * validated, because only the first URL is ours — the rest come from the
62
+ * server;
63
+ * - the `Authorization` header is DROPPED the moment a redirect leaves the
64
+ * origin it was minted for. Registries redirect to CDNs and object stores;
65
+ * forwarding a pull token to `random-bucket.example.com` hands it away.
66
+ */
67
+ export async function httpGet(url, options) {
68
+ const tokenOrigin = url.origin;
69
+ let current = url;
70
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
71
+ if (current.protocol !== "https:" && current.protocol !== "http:") {
72
+ throw new RegistryHttpError(`refusing scheme "${current.protocol.replace(":", "")}" — registry requests are http(s) only`);
73
+ }
74
+ const pinnedIp = await assertNotSsrf(current.hostname);
75
+ const headers = new Headers({ "user-agent": USER_AGENT });
76
+ if (options.accept !== undefined)
77
+ headers.set("accept", options.accept);
78
+ if (options.authorization !== undefined && current.origin === tokenOrigin) {
79
+ headers.set("authorization", options.authorization);
80
+ }
81
+ const res = await rawFetch(new Request(current.toString(), {
82
+ method: options.method ?? "GET",
83
+ redirect: "manual",
84
+ headers,
85
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
86
+ }), pinnedIp);
87
+ if (res.status >= 300 && res.status < 400 && res.headers.has("location")) {
88
+ const location = res.headers.get("location") ?? "";
89
+ let next;
90
+ try {
91
+ next = new URL(location, current);
92
+ }
93
+ catch {
94
+ throw new RegistryHttpError(`registry redirected to an unparseable location "${location}"`);
95
+ }
96
+ try {
97
+ await res.body?.cancel();
98
+ }
99
+ catch {
100
+ // Already discarding this response; a failed cancel changes nothing.
101
+ }
102
+ current = next;
103
+ continue;
104
+ }
105
+ const bytes = await readCapped(res, options.maxBytes);
106
+ return { status: res.status, headers: res.headers, bytes, url: current.toString() };
107
+ }
108
+ throw new RegistryHttpError(`registry redirected more than ${MAX_REDIRECTS} times`);
109
+ }
110
+ /**
111
+ * Drain a body with a hard byte cap, aborting the read once it is exceeded so a
112
+ * hostile or misconfigured registry cannot pin memory. Concatenation happens
113
+ * once at the end, over the exact chunks that arrived.
114
+ */
115
+ async function readCapped(res, maxBytes) {
116
+ if (res.body === null)
117
+ return new Uint8Array(0);
118
+ const reader = res.body.getReader();
119
+ const chunks = [];
120
+ let total = 0;
121
+ try {
122
+ while (true) {
123
+ const { done, value } = await reader.read();
124
+ if (done)
125
+ break;
126
+ if (value === undefined)
127
+ continue;
128
+ total += value.byteLength;
129
+ if (total > maxBytes) {
130
+ try {
131
+ await reader.cancel();
132
+ }
133
+ catch {
134
+ // Already aborting.
135
+ }
136
+ throw new RegistryHttpError(`response body exceeded ${maxBytes} bytes — aborted before reading it all`);
137
+ }
138
+ chunks.push(value);
139
+ }
140
+ }
141
+ finally {
142
+ try {
143
+ reader.releaseLock();
144
+ }
145
+ catch {
146
+ // The stream may already be released; nothing to do.
147
+ }
148
+ }
149
+ const merged = new Uint8Array(total);
150
+ let offset = 0;
151
+ for (const chunk of chunks) {
152
+ merged.set(chunk, offset);
153
+ offset += chunk.byteLength;
154
+ }
155
+ return merged;
156
+ }
157
+ /**
158
+ * A signal that aborts on the caller's cancellation OR after `timeoutMs`,
159
+ * whichever comes first. The returned `dispose` must run in a `finally` so a
160
+ * fast call does not leave a timer holding the process open.
161
+ */
162
+ export function deadlineSignal(outer, timeoutMs) {
163
+ const controller = new AbortController();
164
+ const timer = setTimeout(() => controller.abort(new RegistryHttpError(`registry request timed out after ${timeoutMs}ms`)), timeoutMs);
165
+ const onAbort = () => controller.abort(outer?.reason);
166
+ if (outer !== undefined) {
167
+ if (outer.aborted)
168
+ controller.abort(outer.reason);
169
+ else
170
+ outer.addEventListener("abort", onAbort, { once: true });
171
+ }
172
+ return {
173
+ signal: controller.signal,
174
+ dispose: () => {
175
+ clearTimeout(timer);
176
+ outer?.removeEventListener("abort", onAbort);
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,158 @@
1
+ import { type ImageRef } from "./ref";
2
+ export declare class ManifestError extends Error {
3
+ readonly name = "ManifestError";
4
+ /**
5
+ * True when the bytes in hand are not the bytes that were addressed — a
6
+ * digest that disagrees with its content, from whichever side claimed it.
7
+ * A FLAG rather than a phrase in the message: `withDigests` decides between
8
+ * "note this against one tag" and "abort the whole listing" on this, and a
9
+ * caller that decided by matching message text would fail open the first
10
+ * time somebody reworded a sentence.
11
+ */
12
+ readonly integrity: boolean;
13
+ constructor(message: string, integrity?: boolean);
14
+ }
15
+ export declare const MEDIA_TYPES: {
16
+ readonly dockerManifest: "application/vnd.docker.distribution.manifest.v2+json";
17
+ readonly dockerList: "application/vnd.docker.distribution.manifest.list.v2+json";
18
+ readonly ociManifest: "application/vnd.oci.image.manifest.v1+json";
19
+ readonly ociIndex: "application/vnd.oci.image.index.v1+json";
20
+ readonly dockerSchema1: "application/vnd.docker.distribution.manifest.v1+json";
21
+ readonly dockerSchema1Signed: "application/vnd.docker.distribution.manifest.v1+prettyjws";
22
+ };
23
+ /**
24
+ * Every manifest type we can read, offered in one Accept header. Send all four
25
+ * or a registry will helpfully convert a manifest list down to a single
26
+ * manifest for you — and then the digest you report is not the digest of the
27
+ * tag you asked about.
28
+ */
29
+ export declare const MANIFEST_ACCEPT: string;
30
+ /** sha256 (or sha512) over the exact bytes, formatted as an OCI digest. */
31
+ export declare function digestBytes(bytes: Uint8Array, algorithm?: "sha256" | "sha512"): string;
32
+ export type DigestVerdict = {
33
+ /** The digest WE computed. Everything downstream uses this one. */
34
+ readonly digest: string;
35
+ /** What `Docker-Content-Digest` claimed, when the registry sent it. */
36
+ readonly headerDigest: string | null;
37
+ /** True when a header was present and agreed with the computed digest. */
38
+ readonly headerAgreed: boolean;
39
+ };
40
+ /**
41
+ * Compute the digest of a document and check it against everything that claims
42
+ * to know it: the `Docker-Content-Digest` header, and — when the caller asked
43
+ * for a specific digest, or followed a descriptor out of an index — the digest
44
+ * it was promised.
45
+ *
46
+ * A mismatch is a refusal, never a warning. It means the bytes in hand are not
47
+ * the bytes that were addressed, and there is no answer to give that is both
48
+ * true and useful.
49
+ */
50
+ export declare function verifyDigest(bytes: Uint8Array, header: string | null, expected: string | null, what: string): DigestVerdict;
51
+ /**
52
+ * Decode and parse a document AFTER its digest has been taken. `fatal: true`
53
+ * matters: a lenient decode would replace invalid bytes with U+FFFD and parse a
54
+ * document that is not what was served.
55
+ */
56
+ export declare function parseDocument(bytes: Uint8Array, what: string): Record<string, unknown>;
57
+ export type DocumentKind = "manifest" | "index";
58
+ /**
59
+ * Decide what kind of document this is, preferring what the bytes say about
60
+ * themselves over what the transport said. A registry's Content-Type can be
61
+ * absent, generic (`application/json`), or — behind a proxy — simply wrong,
62
+ * while `mediaType` inside an OCI document is part of the digested content.
63
+ * Structure is the last resort, for old Docker manifests that omit `mediaType`.
64
+ */
65
+ export declare function classifyDocument(contentType: string | null, document: Record<string, unknown>): {
66
+ kind: DocumentKind;
67
+ mediaType: string;
68
+ };
69
+ export type Descriptor = {
70
+ readonly mediaType: string;
71
+ readonly digest: string;
72
+ readonly size: number;
73
+ };
74
+ export type PlatformDescriptor = Descriptor & {
75
+ readonly platform: string;
76
+ readonly os: string;
77
+ readonly architecture: string;
78
+ readonly variant?: string;
79
+ readonly osVersion?: string;
80
+ };
81
+ /** Render a platform the way `docker buildx` prints it: os/arch[/variant]. */
82
+ export declare function platformString(os: string, architecture: string, variant?: string): string;
83
+ /** Parse `linux/arm64/v8` into its parts. Rejects anything else. */
84
+ export declare function parsePlatform(raw: string): {
85
+ os: string;
86
+ architecture: string;
87
+ variant?: string;
88
+ };
89
+ export type IndexEntries = {
90
+ readonly platforms: readonly PlatformDescriptor[];
91
+ /**
92
+ * Entries with no usable platform — buildkit writes provenance and SBOM
93
+ * manifests into the index as `unknown/unknown`, and selecting one of those
94
+ * as "the image" is a classic multi-arch bug.
95
+ */
96
+ readonly attestations: number;
97
+ };
98
+ export declare function readIndex(document: Record<string, unknown>): IndexEntries;
99
+ /**
100
+ * Pick the entry for the wanted platform.
101
+ *
102
+ * A caller who names a variant gets exactly that. A caller who does not gets
103
+ * the variant-less entry when there is one, else the first match in index
104
+ * order — index order, not "best", because the registry's order is the only
105
+ * ranking anyone can reproduce.
106
+ */
107
+ export declare function selectPlatform(entries: readonly PlatformDescriptor[], wanted: {
108
+ os: string;
109
+ architecture: string;
110
+ variant?: string;
111
+ }): PlatformDescriptor | undefined;
112
+ export type ManifestSummary = {
113
+ readonly config: Descriptor;
114
+ readonly layers: readonly Descriptor[];
115
+ readonly totalLayerSize: number;
116
+ /**
117
+ * Layers whose descriptor carried no usable `size`. `totalLayerSize` is the
118
+ * sum of the ones that did, so a non-zero count here means that total is a
119
+ * floor rather than the answer — and somebody sizes a disk on it.
120
+ */
121
+ readonly unsizedLayers: number;
122
+ readonly annotations?: Record<string, string>;
123
+ readonly subject?: string;
124
+ };
125
+ export declare function readManifest(document: Record<string, unknown>): ManifestSummary;
126
+ export type ConfigSummary = {
127
+ readonly created?: string;
128
+ readonly architecture?: string;
129
+ readonly os?: string;
130
+ readonly variant?: string;
131
+ readonly user?: string;
132
+ readonly workingDir?: string;
133
+ readonly entrypoint?: readonly string[];
134
+ readonly cmd?: readonly string[];
135
+ readonly exposedPorts?: readonly string[];
136
+ readonly volumes?: readonly string[];
137
+ readonly labels?: Record<string, string>;
138
+ readonly envNames?: readonly string[];
139
+ readonly diffIds?: number;
140
+ };
141
+ /**
142
+ * The interesting half of the image config blob. Env VALUES are deliberately
143
+ * dropped and only the names kept: a config blob is where a careless build
144
+ * leaves a token, and a tool that echoes every `ENV` into a transcript is a
145
+ * tool that leaks it.
146
+ */
147
+ export declare function readConfig(document: Record<string, unknown>): ConfigSummary;
148
+ /**
149
+ * `/v2/<name>/manifests/<reference>` for a parsed reference.
150
+ *
151
+ * The reference is re-checked against the tag/digest grammar HERE, at the one
152
+ * place it becomes a URL path, because not every caller of this function got
153
+ * its reference from `parseImageRef`: `withDigests` passes tags straight out of
154
+ * the registry's own `/tags/list`.
155
+ */
156
+ export declare function manifestPath(ref: ImageRef, reference?: string): string;
157
+ /** `/v2/<name>/blobs/<digest>` — config blobs only; this package never pulls layers. */
158
+ export declare function blobPath(ref: ImageRef, digest: string): string;