@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,347 @@
1
+ /**
2
+ * Digests, media types and the two shapes a tag can resolve to.
3
+ *
4
+ * THE RULE THIS FILE EXISTS TO ENFORCE: hash first, parse second, and never
5
+ * hash anything but the bytes that arrived. `JSON.parse` followed by
6
+ * `JSON.stringify` changes key order, whitespace and `é`-style escapes —
7
+ * all of which are inside the sha256 preimage. A digest computed from
8
+ * reserialized JSON is not wrong in some subtle academic way; it is a different
9
+ * number, it matches nothing, and it makes "this is the image we tested" false
10
+ * while looking exactly like it is true.
11
+ *
12
+ * So: `digestBytes` takes `Uint8Array`, `parseDocument` is only ever called on
13
+ * a value that has already been hashed, and nothing in this package ever
14
+ * re-serializes a manifest.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { assertPullReference, parseDigest } from "./ref";
18
+ export class ManifestError extends Error {
19
+ name = "ManifestError";
20
+ /**
21
+ * True when the bytes in hand are not the bytes that were addressed — a
22
+ * digest that disagrees with its content, from whichever side claimed it.
23
+ * A FLAG rather than a phrase in the message: `withDigests` decides between
24
+ * "note this against one tag" and "abort the whole listing" on this, and a
25
+ * caller that decided by matching message text would fail open the first
26
+ * time somebody reworded a sentence.
27
+ */
28
+ integrity;
29
+ constructor(message, integrity = false) {
30
+ super(message);
31
+ this.integrity = integrity;
32
+ }
33
+ }
34
+ export const MEDIA_TYPES = {
35
+ dockerManifest: "application/vnd.docker.distribution.manifest.v2+json",
36
+ dockerList: "application/vnd.docker.distribution.manifest.list.v2+json",
37
+ ociManifest: "application/vnd.oci.image.manifest.v1+json",
38
+ ociIndex: "application/vnd.oci.image.index.v1+json",
39
+ dockerSchema1: "application/vnd.docker.distribution.manifest.v1+json",
40
+ dockerSchema1Signed: "application/vnd.docker.distribution.manifest.v1+prettyjws",
41
+ };
42
+ /**
43
+ * Every manifest type we can read, offered in one Accept header. Send all four
44
+ * or a registry will helpfully convert a manifest list down to a single
45
+ * manifest for you — and then the digest you report is not the digest of the
46
+ * tag you asked about.
47
+ */
48
+ export const MANIFEST_ACCEPT = [
49
+ MEDIA_TYPES.ociIndex,
50
+ MEDIA_TYPES.ociManifest,
51
+ MEDIA_TYPES.dockerList,
52
+ MEDIA_TYPES.dockerManifest,
53
+ ].join(", ");
54
+ /** sha256 (or sha512) over the exact bytes, formatted as an OCI digest. */
55
+ export function digestBytes(bytes, algorithm = "sha256") {
56
+ return `${algorithm}:${createHash(algorithm).update(bytes).digest("hex")}`;
57
+ }
58
+ /**
59
+ * Compute the digest of a document and check it against everything that claims
60
+ * to know it: the `Docker-Content-Digest` header, and — when the caller asked
61
+ * for a specific digest, or followed a descriptor out of an index — the digest
62
+ * it was promised.
63
+ *
64
+ * A mismatch is a refusal, never a warning. It means the bytes in hand are not
65
+ * the bytes that were addressed, and there is no answer to give that is both
66
+ * true and useful.
67
+ */
68
+ export function verifyDigest(bytes, header, expected, what) {
69
+ const algorithm = expected === null ? "sha256" : parseDigest(expected).algorithm;
70
+ const digest = digestBytes(bytes, algorithm);
71
+ if (expected !== null && digest !== expected) {
72
+ throw new ManifestError(`${what}: the registry served bytes whose digest is ${digest}, but ${expected} was requested — refusing to report a digest that does not match its content`, true);
73
+ }
74
+ let headerAgreed = false;
75
+ if (header !== null && header.trim() !== "") {
76
+ const claimed = header.trim().toLowerCase();
77
+ // Compare only when the header uses an algorithm we computed; a registry
78
+ // claiming sha512 while we hashed sha256 is a mismatch of question, not of
79
+ // answer, so we recompute in the header's algorithm before disagreeing.
80
+ const claimedAlgorithm = claimed.split(":")[0];
81
+ const ours = claimedAlgorithm === algorithm
82
+ ? digest
83
+ : claimedAlgorithm === "sha256" || claimedAlgorithm === "sha512"
84
+ ? digestBytes(bytes, claimedAlgorithm)
85
+ : null;
86
+ if (ours === null) {
87
+ throw new ManifestError(`${what}: registry claims digest algorithm "${claimedAlgorithm}", which cannot be recomputed here`, true);
88
+ }
89
+ if (ours !== claimed) {
90
+ throw new ManifestError(`${what}: Docker-Content-Digest says ${claimed} but the bytes hash to ${ours} — the header is not evidence, and it disagrees with the content`, true);
91
+ }
92
+ headerAgreed = true;
93
+ }
94
+ return {
95
+ digest,
96
+ headerDigest: header === null ? null : header.trim().toLowerCase(),
97
+ headerAgreed,
98
+ };
99
+ }
100
+ /**
101
+ * Decode and parse a document AFTER its digest has been taken. `fatal: true`
102
+ * matters: a lenient decode would replace invalid bytes with U+FFFD and parse a
103
+ * document that is not what was served.
104
+ */
105
+ export function parseDocument(bytes, what) {
106
+ let text;
107
+ try {
108
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
109
+ }
110
+ catch {
111
+ throw new ManifestError(`${what} is not valid UTF-8`);
112
+ }
113
+ let parsed;
114
+ try {
115
+ parsed = JSON.parse(text);
116
+ }
117
+ catch (err) {
118
+ throw new ManifestError(`${what} is not valid JSON: ${err.message}`);
119
+ }
120
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
121
+ throw new ManifestError(`${what} is not a JSON object`);
122
+ }
123
+ return parsed;
124
+ }
125
+ /**
126
+ * Decide what kind of document this is, preferring what the bytes say about
127
+ * themselves over what the transport said. A registry's Content-Type can be
128
+ * absent, generic (`application/json`), or — behind a proxy — simply wrong,
129
+ * while `mediaType` inside an OCI document is part of the digested content.
130
+ * Structure is the last resort, for old Docker manifests that omit `mediaType`.
131
+ */
132
+ export function classifyDocument(contentType, document) {
133
+ const declared = typeof document["mediaType"] === "string" ? document["mediaType"] : "";
134
+ const transport = (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
135
+ const mediaType = declared !== "" ? declared : transport;
136
+ if (mediaType === MEDIA_TYPES.dockerSchema1 || mediaType === MEDIA_TYPES.dockerSchema1Signed) {
137
+ throw new ManifestError("this tag is a schema 1 manifest, whose digest covers a JWS signature rather than the image content — pull it with a v2 client or ask the registry for a v2 manifest; a schema 1 digest would not mean what the caller thinks it means");
138
+ }
139
+ if (mediaType === MEDIA_TYPES.ociIndex || mediaType === MEDIA_TYPES.dockerList) {
140
+ return { kind: "index", mediaType };
141
+ }
142
+ if (mediaType === MEDIA_TYPES.ociManifest || mediaType === MEDIA_TYPES.dockerManifest) {
143
+ return { kind: "manifest", mediaType };
144
+ }
145
+ // No usable media type: fall back to shape. `manifests` is an index, a
146
+ // `config` descriptor plus `layers` is an image manifest.
147
+ if (Array.isArray(document["manifests"])) {
148
+ return { kind: "index", mediaType: mediaType === "" ? MEDIA_TYPES.ociIndex : mediaType };
149
+ }
150
+ if (Array.isArray(document["layers"]) && typeof document["config"] === "object") {
151
+ return { kind: "manifest", mediaType: mediaType === "" ? MEDIA_TYPES.ociManifest : mediaType };
152
+ }
153
+ if (document["schemaVersion"] === 1 || Array.isArray(document["fsLayers"])) {
154
+ throw new ManifestError("this tag is a schema 1 manifest (fsLayers/schemaVersion 1) — its digest covers a signature envelope, not the image content");
155
+ }
156
+ throw new ManifestError(`cannot tell what kind of document this is — media type "${mediaType || "(none)"}", and it has neither "manifests" nor "layers"`);
157
+ }
158
+ function readDescriptor(raw, what) {
159
+ if (typeof raw !== "object" || raw === null)
160
+ throw new ManifestError(`${what} is not a descriptor`);
161
+ const d = raw;
162
+ const rawDigest = typeof d["digest"] === "string" ? d["digest"] : "";
163
+ if (rawDigest === "")
164
+ throw new ManifestError(`${what} has no digest`);
165
+ // Refuse an unverifiable algorithm here, not three calls later — and store
166
+ // the NORMALISED form. Hex case is not identity: a descriptor written
167
+ // `sha256:AB…` addresses the same bytes as `sha256:ab…`, but comparing it
168
+ // against a freshly computed (lower-case) digest would raise the tampering
169
+ // refusal, which has to stay believable.
170
+ const parsed = parseDigest(rawDigest);
171
+ return {
172
+ mediaType: typeof d["mediaType"] === "string" ? d["mediaType"] : "",
173
+ digest: `${parsed.algorithm}:${parsed.hex}`,
174
+ size: typeof d["size"] === "number" && Number.isFinite(d["size"]) ? d["size"] : -1,
175
+ };
176
+ }
177
+ /** Render a platform the way `docker buildx` prints it: os/arch[/variant]. */
178
+ export function platformString(os, architecture, variant) {
179
+ return variant === undefined || variant === ""
180
+ ? `${os}/${architecture}`
181
+ : `${os}/${architecture}/${variant}`;
182
+ }
183
+ /** Parse `linux/arm64/v8` into its parts. Rejects anything else. */
184
+ export function parsePlatform(raw) {
185
+ const parts = raw
186
+ .trim()
187
+ .toLowerCase()
188
+ .split("/")
189
+ .filter((p) => p !== "");
190
+ if (parts.length < 2 || parts.length > 3) {
191
+ throw new ManifestError(`"${raw}" is not a platform — write os/arch or os/arch/variant, e.g. linux/arm64/v8`);
192
+ }
193
+ const [os, architecture, variant] = parts;
194
+ return variant === undefined ? { os, architecture } : { os, architecture, variant };
195
+ }
196
+ export function readIndex(document) {
197
+ const raw = document["manifests"];
198
+ if (!Array.isArray(raw))
199
+ throw new ManifestError("index has no manifests array");
200
+ const platforms = [];
201
+ let attestations = 0;
202
+ for (const [i, entry] of raw.entries()) {
203
+ const descriptor = readDescriptor(entry, `index entry ${i}`);
204
+ const p = entry["platform"];
205
+ const platform = typeof p === "object" && p !== null ? p : {};
206
+ const os = typeof platform["os"] === "string" ? platform["os"] : "";
207
+ const architecture = typeof platform["architecture"] === "string" ? platform["architecture"] : "";
208
+ const variant = typeof platform["variant"] === "string" ? platform["variant"] : undefined;
209
+ const osVersion = typeof platform["os.version"] === "string" ? platform["os.version"] : undefined;
210
+ if (os === "" || architecture === "" || os === "unknown" || architecture === "unknown") {
211
+ attestations++;
212
+ continue;
213
+ }
214
+ platforms.push({
215
+ ...descriptor,
216
+ platform: platformString(os, architecture, variant),
217
+ os,
218
+ architecture,
219
+ ...(variant !== undefined ? { variant } : {}),
220
+ ...(osVersion !== undefined ? { osVersion } : {}),
221
+ });
222
+ }
223
+ return { platforms, attestations };
224
+ }
225
+ /**
226
+ * Pick the entry for the wanted platform.
227
+ *
228
+ * A caller who names a variant gets exactly that. A caller who does not gets
229
+ * the variant-less entry when there is one, else the first match in index
230
+ * order — index order, not "best", because the registry's order is the only
231
+ * ranking anyone can reproduce.
232
+ */
233
+ export function selectPlatform(entries, wanted) {
234
+ const matches = entries.filter((e) => e.os === wanted.os && e.architecture === wanted.architecture);
235
+ if (matches.length === 0)
236
+ return undefined;
237
+ if (wanted.variant !== undefined)
238
+ return matches.find((e) => e.variant === wanted.variant);
239
+ return matches.find((e) => e.variant === undefined) ?? matches[0];
240
+ }
241
+ export function readManifest(document) {
242
+ const config = readDescriptor(document["config"], "manifest config");
243
+ const rawLayers = document["layers"];
244
+ if (!Array.isArray(rawLayers))
245
+ throw new ManifestError("manifest has no layers array");
246
+ const layers = rawLayers.map((l, i) => readDescriptor(l, `layer ${i}`));
247
+ const totalLayerSize = layers.reduce((sum, l) => sum + (l.size > 0 ? l.size : 0), 0);
248
+ const unsizedLayers = layers.filter((l) => l.size < 0).length;
249
+ const annotations = readStringMap(document["annotations"]);
250
+ const subject = document["subject"];
251
+ const subjectDigest = typeof subject === "object" &&
252
+ subject !== null &&
253
+ typeof subject["digest"] === "string"
254
+ ? subject["digest"]
255
+ : undefined;
256
+ return {
257
+ config,
258
+ layers,
259
+ totalLayerSize,
260
+ unsizedLayers,
261
+ ...(annotations !== undefined ? { annotations } : {}),
262
+ ...(subjectDigest !== undefined ? { subject: subjectDigest } : {}),
263
+ };
264
+ }
265
+ /**
266
+ * The interesting half of the image config blob. Env VALUES are deliberately
267
+ * dropped and only the names kept: a config blob is where a careless build
268
+ * leaves a token, and a tool that echoes every `ENV` into a transcript is a
269
+ * tool that leaks it.
270
+ */
271
+ export function readConfig(document) {
272
+ const cfg = typeof document["config"] === "object" && document["config"] !== null
273
+ ? document["config"]
274
+ : {};
275
+ const rootfs = typeof document["rootfs"] === "object" && document["rootfs"] !== null
276
+ ? document["rootfs"]
277
+ : {};
278
+ const diffIds = Array.isArray(rootfs["diff_ids"])
279
+ ? rootfs["diff_ids"].length
280
+ : undefined;
281
+ const env = Array.isArray(cfg["Env"])
282
+ ? cfg["Env"]
283
+ .filter((e) => typeof e === "string")
284
+ .map((e) => (e.includes("=") ? e.split("=")[0] : e))
285
+ : undefined;
286
+ return {
287
+ ...pickString(document, "created"),
288
+ ...pickString(document, "architecture"),
289
+ ...pickString(document, "os"),
290
+ ...pickString(document, "variant"),
291
+ ...pickString(cfg, "User", "user"),
292
+ ...pickString(cfg, "WorkingDir", "workingDir"),
293
+ ...pickStrings(cfg, "Entrypoint", "entrypoint"),
294
+ ...pickStrings(cfg, "Cmd", "cmd"),
295
+ ...(readKeySet(cfg["ExposedPorts"]) !== undefined
296
+ ? { exposedPorts: readKeySet(cfg["ExposedPorts"]) }
297
+ : {}),
298
+ ...(readKeySet(cfg["Volumes"]) !== undefined ? { volumes: readKeySet(cfg["Volumes"]) } : {}),
299
+ ...(readStringMap(cfg["Labels"]) !== undefined ? { labels: readStringMap(cfg["Labels"]) } : {}),
300
+ ...(env !== undefined ? { envNames: env } : {}),
301
+ ...(diffIds !== undefined ? { diffIds } : {}),
302
+ };
303
+ }
304
+ function pickString(source, key, as = key) {
305
+ const value = source[key];
306
+ return typeof value === "string" && value !== "" ? { [as]: value } : {};
307
+ }
308
+ function pickStrings(source, key, as) {
309
+ const value = source[key];
310
+ if (!Array.isArray(value))
311
+ return {};
312
+ const strings = value.filter((v) => typeof v === "string");
313
+ return strings.length === 0 ? {} : { [as]: strings };
314
+ }
315
+ function readKeySet(value) {
316
+ if (typeof value !== "object" || value === null || Array.isArray(value))
317
+ return undefined;
318
+ const keys = Object.keys(value).sort();
319
+ return keys.length === 0 ? undefined : keys;
320
+ }
321
+ function readStringMap(value) {
322
+ if (typeof value !== "object" || value === null || Array.isArray(value))
323
+ return undefined;
324
+ const out = {};
325
+ for (const [k, v] of Object.entries(value)) {
326
+ if (typeof v === "string")
327
+ out[k] = v;
328
+ }
329
+ return Object.keys(out).length === 0 ? undefined : out;
330
+ }
331
+ /**
332
+ * `/v2/<name>/manifests/<reference>` for a parsed reference.
333
+ *
334
+ * The reference is re-checked against the tag/digest grammar HERE, at the one
335
+ * place it becomes a URL path, because not every caller of this function got
336
+ * its reference from `parseImageRef`: `withDigests` passes tags straight out of
337
+ * the registry's own `/tags/list`.
338
+ */
339
+ export function manifestPath(ref, reference = ref.reference) {
340
+ assertPullReference(reference);
341
+ return `/v2/${ref.repository}/manifests/${reference}`;
342
+ }
343
+ /** `/v2/<name>/blobs/<digest>` — config blobs only; this package never pulls layers. */
344
+ export function blobPath(ref, digest) {
345
+ parseDigest(digest); // same containment rule: a blob is only ever addressed by digest
346
+ return `/v2/${ref.repository}/blobs/${digest}`;
347
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Image reference parsing — the `[registry/]repository[:tag][@digest]` grammar
3
+ * every container tool in the world accepts and almost nobody writes down.
4
+ *
5
+ * Two things here are load-bearing rather than cosmetic:
6
+ *
7
+ * 1. The repository is interpolated into a URL path (`/v2/<name>/manifests/…`).
8
+ * Anything outside the OCI grammar is REFUSED rather than escaped, so a
9
+ * reference like `ghcr.io/a/../../v2/other/manifests/latest` cannot walk
10
+ * the client onto a different repository — or off `/v2/` entirely.
11
+ * 2. Docker Hub is not `docker.io`. The API host is `registry-1.docker.io`
12
+ * and a one-segment name means the `library/` namespace, so `alpine` is
13
+ * `registry-1.docker.io/v2/library/alpine`. Every other registry uses the
14
+ * name as written.
15
+ */
16
+ export declare class ImageRefError extends Error {
17
+ readonly name = "ImageRefError";
18
+ }
19
+ export type ImageRef = {
20
+ /** What the caller means by the registry — `docker.io`, not the API host. */
21
+ readonly registry: string;
22
+ /** The host actually contacted; differs from `registry` only for Docker Hub. */
23
+ readonly apiHost: string;
24
+ /** Fully qualified repository, `library/` synthesised for Hub official images. */
25
+ readonly repository: string;
26
+ /** The tag or digest this reference selects. */
27
+ readonly reference: string;
28
+ readonly referenceKind: "tag" | "digest";
29
+ /** Normalised `registry/repository:tag` (or `@digest`), for echoing back. */
30
+ readonly canonical: string;
31
+ };
32
+ /**
33
+ * Validate a digest string and return its algorithm. Refuses an algorithm we
34
+ * cannot recompute: reporting a digest we could not check against the bytes
35
+ * would be exactly the lie this package exists to avoid.
36
+ */
37
+ export declare function parseDigest(raw: string): {
38
+ algorithm: string;
39
+ hex: string;
40
+ };
41
+ export type ParseOptions = {
42
+ /**
43
+ * `false` for the tags endpoint, which addresses a repository and not one
44
+ * version of it. A tag is then still parsed and reported, so a caller who
45
+ * pastes a full reference gets an answer instead of a lecture.
46
+ */
47
+ readonly defaultTag?: boolean;
48
+ };
49
+ /**
50
+ * Parse `[registry/]repository[:tag][@digest]`.
51
+ *
52
+ * When both a tag and a digest are present (`alpine:3.19@sha256:…`, which is
53
+ * what a pinned reference looks like) the DIGEST wins, because that is what the
54
+ * registry will serve — the tag in such a reference is a human label and may
55
+ * have been moved since it was written down.
56
+ */
57
+ export declare function parseImageRef(raw: string, options?: ParseOptions): ImageRef;
58
+ /** True when `value` is a tag under the distribution grammar. */
59
+ export declare function isValidTag(value: string): boolean;
60
+ /**
61
+ * Refuse a reference that is neither a tag nor a digest BEFORE it reaches
62
+ * `/v2/<name>/manifests/<reference>`.
63
+ *
64
+ * A caller's reference arrives through `parseImageRef` and is already checked.
65
+ * A tag read out of `/tags/list` is NOT: it is a JSON string the registry
66
+ * chose, and `new URL("https://reg/v2/owner/app/manifests/" + "../../victim/manifests/latest")`
67
+ * normalises to `https://reg/v2/victim/manifests/latest` — a different
68
+ * repository, whose digest would then be reported as this one's tag. For a URL
69
+ * path, containment is a grammar check; escaping the string would only turn a
70
+ * silent redirection into a 404.
71
+ */
72
+ export declare function assertPullReference(reference: string): void;
73
+ /** The scope a pull token must ask for, for this repository. */
74
+ export declare function pullScope(repository: string): string;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Image reference parsing — the `[registry/]repository[:tag][@digest]` grammar
3
+ * every container tool in the world accepts and almost nobody writes down.
4
+ *
5
+ * Two things here are load-bearing rather than cosmetic:
6
+ *
7
+ * 1. The repository is interpolated into a URL path (`/v2/<name>/manifests/…`).
8
+ * Anything outside the OCI grammar is REFUSED rather than escaped, so a
9
+ * reference like `ghcr.io/a/../../v2/other/manifests/latest` cannot walk
10
+ * the client onto a different repository — or off `/v2/` entirely.
11
+ * 2. Docker Hub is not `docker.io`. The API host is `registry-1.docker.io`
12
+ * and a one-segment name means the `library/` namespace, so `alpine` is
13
+ * `registry-1.docker.io/v2/library/alpine`. Every other registry uses the
14
+ * name as written.
15
+ */
16
+ /** Registries that mean Docker Hub, whatever the caller typed. */
17
+ const DOCKER_HUB_ALIASES = new Set(["docker.io", "index.docker.io", "registry-1.docker.io"]);
18
+ const DOCKER_HUB_API_HOST = "registry-1.docker.io";
19
+ /**
20
+ * One path component of a repository name, per the distribution spec:
21
+ * lowercase alphanumerics with single `.`/`_`, double `__`, or runs of `-` as
22
+ * separators. Uppercase is invalid — refusing `Alpine` is kinder than the 404
23
+ * the registry would answer with.
24
+ */
25
+ const COMPONENT_RE = /^[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*$/;
26
+ /** A tag: up to 128 characters, no leading `.` or `-`. */
27
+ const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
28
+ /** A registry host, optionally with a port. No scheme, no path, no userinfo. */
29
+ const HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*(?::\d{1,5})?$/;
30
+ /** OCI digest grammar: `<algorithm>:<encoded>`. */
31
+ const DIGEST_RE = /^([a-z0-9]+(?:[.+_-][a-z0-9]+)*):([a-zA-Z0-9=_-]+)$/;
32
+ /** Hex length of the digests we can actually recompute from response bytes. */
33
+ const SUPPORTED_DIGESTS = { sha256: 64, sha512: 128 };
34
+ export class ImageRefError extends Error {
35
+ name = "ImageRefError";
36
+ }
37
+ /**
38
+ * Validate a digest string and return its algorithm. Refuses an algorithm we
39
+ * cannot recompute: reporting a digest we could not check against the bytes
40
+ * would be exactly the lie this package exists to avoid.
41
+ */
42
+ export function parseDigest(raw) {
43
+ const m = DIGEST_RE.exec(raw);
44
+ if (!m) {
45
+ throw new ImageRefError(`"${raw}" is not a digest — expected <algorithm>:<hex>, e.g. sha256:…`);
46
+ }
47
+ const algorithm = m[1];
48
+ const hex = m[2].toLowerCase();
49
+ const expected = SUPPORTED_DIGESTS[algorithm];
50
+ if (expected === undefined) {
51
+ throw new ImageRefError(`digest algorithm "${algorithm}" is not supported — this tool verifies sha256 and sha512, and will not report a digest it cannot recompute`);
52
+ }
53
+ if (hex.length !== expected || !/^[0-9a-f]+$/.test(hex)) {
54
+ throw new ImageRefError(`"${raw}" is not a valid ${algorithm} digest — expected ${expected} hex characters, got ${hex.length}`);
55
+ }
56
+ return { algorithm, hex };
57
+ }
58
+ /** True when the first path segment names a registry rather than a namespace. */
59
+ function looksLikeHost(segment) {
60
+ return segment.includes(".") || segment.includes(":") || segment === "localhost";
61
+ }
62
+ /**
63
+ * Parse `[registry/]repository[:tag][@digest]`.
64
+ *
65
+ * When both a tag and a digest are present (`alpine:3.19@sha256:…`, which is
66
+ * what a pinned reference looks like) the DIGEST wins, because that is what the
67
+ * registry will serve — the tag in such a reference is a human label and may
68
+ * have been moved since it was written down.
69
+ */
70
+ export function parseImageRef(raw, options = {}) {
71
+ const input = raw.trim();
72
+ if (input === "")
73
+ throw new ImageRefError("image reference is empty");
74
+ if (input.length > 512) {
75
+ throw new ImageRefError(`image reference is ${input.length} characters — that is not a reference`);
76
+ }
77
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(input)) {
78
+ throw new ImageRefError(`"${input}" has a URL scheme — an image reference has none; write ghcr.io/owner/name:tag`);
79
+ }
80
+ let rest = input;
81
+ let digest = null;
82
+ const at = rest.indexOf("@");
83
+ if (at !== -1) {
84
+ const tail = rest.slice(at + 1);
85
+ if (tail.includes("@")) {
86
+ throw new ImageRefError(`"${input}" has more than one "@" — expected at most name@digest`);
87
+ }
88
+ const parsed = parseDigest(tail);
89
+ digest = `${parsed.algorithm}:${parsed.hex}`;
90
+ rest = rest.slice(0, at);
91
+ }
92
+ // Split the registry off only when the first segment is host-shaped. `foo/bar`
93
+ // is a Docker Hub namespace, `foo.io/bar` is a registry — that single rule is
94
+ // the whole disambiguation, and it is why `localhost` is special-cased.
95
+ let registryRaw = "";
96
+ let name = rest;
97
+ const slash = rest.indexOf("/");
98
+ if (slash !== -1) {
99
+ const head = rest.slice(0, slash);
100
+ if (looksLikeHost(head)) {
101
+ registryRaw = head;
102
+ name = rest.slice(slash + 1);
103
+ }
104
+ }
105
+ // The tag separator is the last `:` that cannot be a registry port, i.e. one
106
+ // that appears after the final `/`.
107
+ let tag = null;
108
+ const lastSlash = name.lastIndexOf("/");
109
+ const colon = name.indexOf(":", lastSlash + 1);
110
+ if (colon !== -1) {
111
+ tag = name.slice(colon + 1);
112
+ name = name.slice(0, colon);
113
+ if (!TAG_RE.test(tag)) {
114
+ throw new ImageRefError(`"${tag}" is not a valid tag — tags are up to 128 characters of letters, digits, ".", "_" and "-", and cannot start with "." or "-"`);
115
+ }
116
+ }
117
+ if (registryRaw !== "" && !HOST_RE.test(registryRaw)) {
118
+ throw new ImageRefError(`"${registryRaw}" is not a valid registry host`);
119
+ }
120
+ // HOST_RE allows five port digits; 65536..99999 are not ports. Without this
121
+ // the refusal is a raw `TypeError: … cannot be parsed as a URL` thrown from
122
+ // `new URL` three calls later, which names neither the input nor the rule.
123
+ const port = registryRaw.slice(registryRaw.lastIndexOf(":") + 1);
124
+ if (registryRaw.includes(":") && (Number(port) < 1 || Number(port) > 65535)) {
125
+ throw new ImageRefError(`"${registryRaw}" has port ${port}, which is not a port`);
126
+ }
127
+ if (name === "")
128
+ throw new ImageRefError(`"${input}" has no repository name`);
129
+ const components = name.split("/");
130
+ for (const component of components) {
131
+ if (component === "") {
132
+ throw new ImageRefError(`"${name}" has an empty path component`);
133
+ }
134
+ if (!COMPONENT_RE.test(component)) {
135
+ const hint = /[A-Z]/.test(component)
136
+ ? " — repository names are lowercase"
137
+ : component === "." || component === ".."
138
+ ? " — relative path segments are not part of a repository name"
139
+ : "";
140
+ throw new ImageRefError(`"${component}" is not a valid repository path component${hint}`);
141
+ }
142
+ }
143
+ if (components.length > 8) {
144
+ throw new ImageRefError(`"${name}" has ${components.length} path components — expected at most 8`);
145
+ }
146
+ const hubAlias = registryRaw === "" || DOCKER_HUB_ALIASES.has(registryRaw.toLowerCase());
147
+ const registry = hubAlias ? "docker.io" : registryRaw.toLowerCase();
148
+ const apiHost = hubAlias ? DOCKER_HUB_API_HOST : registry;
149
+ // Hub's official images live under `library/`; the short form everyone types
150
+ // is the only place in this grammar where a path segment is synthesised.
151
+ const repository = hubAlias && components.length === 1 ? `library/${name}` : name;
152
+ const defaultTag = options.defaultTag ?? true;
153
+ const reference = digest ?? tag ?? (defaultTag ? "latest" : "");
154
+ const referenceKind = digest !== null ? "digest" : "tag";
155
+ const canonical = digest !== null
156
+ ? `${registry}/${repository}@${digest}`
157
+ : reference === ""
158
+ ? `${registry}/${repository}`
159
+ : `${registry}/${repository}:${reference}`;
160
+ return { registry, apiHost, repository, reference, referenceKind, canonical };
161
+ }
162
+ /** True when `value` is a tag under the distribution grammar. */
163
+ export function isValidTag(value) {
164
+ return TAG_RE.test(value);
165
+ }
166
+ /**
167
+ * Refuse a reference that is neither a tag nor a digest BEFORE it reaches
168
+ * `/v2/<name>/manifests/<reference>`.
169
+ *
170
+ * A caller's reference arrives through `parseImageRef` and is already checked.
171
+ * A tag read out of `/tags/list` is NOT: it is a JSON string the registry
172
+ * chose, and `new URL("https://reg/v2/owner/app/manifests/" + "../../victim/manifests/latest")`
173
+ * normalises to `https://reg/v2/victim/manifests/latest` — a different
174
+ * repository, whose digest would then be reported as this one's tag. For a URL
175
+ * path, containment is a grammar check; escaping the string would only turn a
176
+ * silent redirection into a 404.
177
+ */
178
+ export function assertPullReference(reference) {
179
+ if (isValidTag(reference))
180
+ return;
181
+ // Digest-shaped gets the digest parser's specific reason (bad length,
182
+ // unsupported algorithm) rather than this generic one.
183
+ if (DIGEST_RE.test(reference)) {
184
+ parseDigest(reference);
185
+ return;
186
+ }
187
+ throw new ImageRefError(`"${reference.length > 80 ? `${reference.slice(0, 80)}…` : reference}" is neither a tag nor a digest — refusing to put it in a registry URL path`);
188
+ }
189
+ /** The scope a pull token must ask for, for this repository. */
190
+ export function pullScope(repository) {
191
+ return `repository:${repository}:pull`;
192
+ }
@@ -0,0 +1,37 @@
1
+ import { type RawResponse, RegistryHttpError } from "./http";
2
+ export declare class RegistryError extends Error {
3
+ readonly name = "RegistryError";
4
+ readonly status: number;
5
+ constructor(message: string, status?: number);
6
+ }
7
+ export type RegistryClient = {
8
+ readonly host: string;
9
+ readonly signal?: AbortSignal;
10
+ readonly tokens: Map<string, string>;
11
+ /** Requests actually issued, reported back so a caller can see what it cost. */
12
+ requests: number;
13
+ readonly timeoutMs: number;
14
+ };
15
+ export declare function newClient(host: string, signal?: AbortSignal, timeoutMs?: number): RegistryClient;
16
+ export type RegistryGetOptions = {
17
+ readonly accept?: string;
18
+ readonly maxBytes: number;
19
+ /** The pull scope this path needs, e.g. `repository:library/alpine:pull`. */
20
+ readonly scope: string;
21
+ };
22
+ /** `https://<host><path>` — the only place a registry URL is constructed. */
23
+ export declare function registryUrl(host: string, path: string): URL;
24
+ /**
25
+ * GET a registry path, performing the anonymous token dance if the registry
26
+ * asks for one. Returns the raw bytes; the caller decides whether to hash them,
27
+ * parse them, or both — in that order.
28
+ */
29
+ export declare function registryGet(client: RegistryClient, path: string, options: RegistryGetOptions): Promise<RawResponse>;
30
+ /**
31
+ * The same dance against an already-built URL — what paging needs, since the
32
+ * next page arrives as a `Link` header rather than a path we compose. The
33
+ * caller has already checked that the URL is on the registry's own origin.
34
+ */
35
+ export declare function registryGetUrl(client: RegistryClient, url: URL, options: RegistryGetOptions): Promise<RawResponse>;
36
+ export declare function describeFailure(res: RawResponse, host: string, path: string): RegistryError;
37
+ export { RegistryHttpError };