@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/README.md +136 -0
- package/dist/fixtures.d.ts +93 -0
- package/dist/fixtures.js +244 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +475 -0
- package/dist/lib/challenge.d.ts +52 -0
- package/dist/lib/challenge.js +174 -0
- package/dist/lib/http.d.ts +48 -0
- package/dist/lib/http.js +179 -0
- package/dist/lib/manifest.d.ts +158 -0
- package/dist/lib/manifest.js +347 -0
- package/dist/lib/ref.d.ts +74 -0
- package/dist/lib/ref.js +192 -0
- package/dist/lib/registry.d.ts +37 -0
- package/dist/lib/registry.js +166 -0
- package/dist/lib/tags.d.ts +86 -0
- package/dist/lib/tags.js +244 -0
- package/package.json +46 -0
|
@@ -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;
|
package/dist/lib/ref.js
ADDED
|
@@ -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 };
|