@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
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# @crewhaus/tool-containers
|
|
2
|
+
|
|
3
|
+
What is actually behind this image tag.
|
|
4
|
+
|
|
5
|
+
| Tool | Answers |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `ContainerImageInspect` | which digest does this tag resolve to, and what is inside it |
|
|
8
|
+
| `ContainerImageTags` | what versions of this image exist |
|
|
9
|
+
|
|
10
|
+
Both talk to the OCI distribution API directly. No daemon, no `docker pull`, no
|
|
11
|
+
layers downloaded, nothing written to disk — resolving a tag to a digest costs
|
|
12
|
+
one or two small GETs, and the image config blob (labels, entrypoint, created
|
|
13
|
+
time) costs one more.
|
|
14
|
+
|
|
15
|
+
## The digest is computed, not believed
|
|
16
|
+
|
|
17
|
+
A manifest's digest is `sha256` over the exact bytes the registry sent. That
|
|
18
|
+
makes it fragile in a specific way: parse the JSON and serialize it again — a
|
|
19
|
+
different key order, two spaces instead of three, a literal `é` where the
|
|
20
|
+
registry wrote `é` — and the hash is a different number that still looks
|
|
21
|
+
exactly like a digest. Every image you "pinned" then matches nothing, and "this
|
|
22
|
+
is the image we tested" is false while reading as true.
|
|
23
|
+
|
|
24
|
+
So the bytes are held as bytes from the socket to the hash. Nothing on the
|
|
25
|
+
digest path is parsed before it is hashed, and nothing is ever re-serialized.
|
|
26
|
+
The registry's `Docker-Content-Digest` header is **checked against the computed
|
|
27
|
+
digest, never copied from it**, and the result reports both whether a header was
|
|
28
|
+
present and whether it agreed. When you ask for `…@sha256:abc`, or when a
|
|
29
|
+
multi-arch index promises a per-platform digest, the bytes that come back are
|
|
30
|
+
verified against that promise too, and a mismatch is a refusal rather than a
|
|
31
|
+
field in the output.
|
|
32
|
+
|
|
33
|
+
## Multi-arch is two answers, so it returns two
|
|
34
|
+
|
|
35
|
+
A tag on a modern image is usually an **index**, not an image: one digest
|
|
36
|
+
covering several per-platform manifests. Both digests are real and they are used
|
|
37
|
+
for different things, so both are reported — `resolved` is what the tag points
|
|
38
|
+
at, `manifest` is the single-platform image inside it (`linux/amd64` by default,
|
|
39
|
+
`platform` to choose, `indexOnly` to stop at the index).
|
|
40
|
+
|
|
41
|
+
Buildkit also writes provenance and SBOM manifests into the index with the
|
|
42
|
+
platform `unknown/unknown`. Those are counted as `attestations` and are never
|
|
43
|
+
selectable — picking one and reporting it as the image is the classic multi-arch
|
|
44
|
+
bug.
|
|
45
|
+
|
|
46
|
+
Two more things the platform string alone does not settle. Asking for
|
|
47
|
+
`linux/arm64` gets the **variant-less** entry when the index has one, whatever
|
|
48
|
+
order the index lists it in, because that is the image a plain arm64 host would
|
|
49
|
+
pull. And `windows/amd64` is usually several images that differ only in
|
|
50
|
+
`os.version`; when more than one entry matches, the chosen one's `osVersion` and
|
|
51
|
+
a `platformMatches` count travel with the answer, because a digest picked
|
|
52
|
+
blindly out of that set will not run.
|
|
53
|
+
|
|
54
|
+
## The auth dance, from the challenge
|
|
55
|
+
|
|
56
|
+
Public images need a token: the registry answers 401 with a
|
|
57
|
+
`WWW-Authenticate: Bearer realm=…,service=…` challenge, the client fetches a
|
|
58
|
+
token from that realm and retries. This is driven entirely off the challenge, so
|
|
59
|
+
Docker Hub, ghcr.io and quay.io all work without appearing in the code — and so
|
|
60
|
+
does the registry nobody here has tried yet. The one registry-specific rule is a
|
|
61
|
+
naming one: `alpine` means `registry-1.docker.io/v2/library/alpine`.
|
|
62
|
+
|
|
63
|
+
The challenge is data from a server, so it is treated as such. The realm must be
|
|
64
|
+
an absolute `https` URL with no credentials in it, and the scope requested is
|
|
65
|
+
**ours** — a challenge asking us to request `repository:someone/else:push` gets
|
|
66
|
+
a pull scope for the repository the caller actually named.
|
|
67
|
+
|
|
68
|
+
## What it refuses
|
|
69
|
+
|
|
70
|
+
A tool that cannot say no is not finished. These say no, with the reason:
|
|
71
|
+
|
|
72
|
+
- **a digest that does not match its content** — from the header, from the
|
|
73
|
+
reference, or from an index descriptor;
|
|
74
|
+
- **a private repository** — after an anonymous token has been tried, reported
|
|
75
|
+
as private rather than as missing, because no retry fixes it;
|
|
76
|
+
- **a registry offering only Basic auth**, naming what it offered;
|
|
77
|
+
- **a schema 1 manifest**, whose digest covers a JWS signature rather than the
|
|
78
|
+
image content;
|
|
79
|
+
- **a platform the index does not have**, listing the ones it does;
|
|
80
|
+
- **a redirect or an auth realm pointing into the private network** — the SSRF
|
|
81
|
+
guard from `@crewhaus/tool-fetch` runs on every hop, with the connection
|
|
82
|
+
pinned to the IP it validated, so a registry cannot answer a manifest request
|
|
83
|
+
with `302 Location: http://169.254.169.254/…`;
|
|
84
|
+
- **paging that leaves the registry's origin**, because a `Link` header is
|
|
85
|
+
server-controlled and the alternative is being walked onto another host;
|
|
86
|
+
- **`withDigests` over its fan-out cap**, with the instruction to narrow, rather
|
|
87
|
+
than quietly crawling somebody's registry;
|
|
88
|
+
- **a reference that is neither a tag nor a digest**, checked at the one place a
|
|
89
|
+
string becomes a URL path. A tag list is server-controlled data, and
|
|
90
|
+
`https://reg/v2/owner/app/manifests/` + `../../victim/manifests/latest`
|
|
91
|
+
normalises to a different repository — whose digest would then be reported as
|
|
92
|
+
this one's. Entries outside the tag grammar are dropped from a tag list and
|
|
93
|
+
counted as `malformed` rather than carried; a registry host with a port above
|
|
94
|
+
65535 is refused by name instead of by a URL parser.
|
|
95
|
+
|
|
96
|
+
A pull token is also dropped the moment a redirect leaves the origin it was
|
|
97
|
+
minted for. Registries redirect to CDNs and object stores; forwarding the token
|
|
98
|
+
hands it to whoever runs them.
|
|
99
|
+
|
|
100
|
+
## Tag lists are not semver, and are sorted anyway
|
|
101
|
+
|
|
102
|
+
A real repository holds `latest`, `edge`, `sha-9f3c1a`, `0.6` and `v1.0.0-rc.1`
|
|
103
|
+
side by side. The default sort partitions: versions first by semver precedence,
|
|
104
|
+
newest first, then everything else in lexicographic order. No *tag* is dropped
|
|
105
|
+
and nothing throws — an unparseable version is still a tag, not an error.
|
|
106
|
+
`1.0.0-alpha.10` sorts above `1.0.0-alpha.2`, because those identifiers are
|
|
107
|
+
numbers.
|
|
108
|
+
|
|
109
|
+
`match` is a glob over tag names with `*` and `?`. It is matched with a
|
|
110
|
+
two-pointer scan rather than a compiled regex: `*` as `[^]*` makes
|
|
111
|
+
`*a*a*a*a*a*a*a*a*z` cost C(n, 8) backtracks against an n-character tag, which
|
|
112
|
+
for a full-length 128-character tag is hours of CPU inside one synchronous
|
|
113
|
+
`RegExp.test` — where neither `timeoutMs` nor a cancellation signal can reach
|
|
114
|
+
it, since both only take effect between awaits.
|
|
115
|
+
|
|
116
|
+
`withDigests` resolves each returned tag with a GET and verifies it, rather than
|
|
117
|
+
a HEAD: a `HEAD` gives you the registry's claim about a digest and no bytes to
|
|
118
|
+
check it against, which is the one thing this package will not report. The limit
|
|
119
|
+
is applied before the fan-out, never after.
|
|
120
|
+
|
|
121
|
+
## What it does not do
|
|
122
|
+
|
|
123
|
+
- **It does not pull.** Layers are never fetched; only the manifest and, on
|
|
124
|
+
request, the image config blob.
|
|
125
|
+
- **It does not authenticate.** Anonymous flow only — no credentials are read,
|
|
126
|
+
stored or sent, and a private repository is refused.
|
|
127
|
+
- **It does not echo environment values.** A config blob is where a careless
|
|
128
|
+
build leaves a token, so `envNames` lists the names and drops the values.
|
|
129
|
+
- **It does not sort by creation time.** That would cost a manifest plus a
|
|
130
|
+
config blob per tag to order a list, and the answer would still be the time a
|
|
131
|
+
config was built rather than the time a tag was pushed.
|
|
132
|
+
- **It does not invent a total.** `totalLayerSize` sums the layer descriptors
|
|
133
|
+
that carried a `size`; when any did not, `unsizedLayers` says how many, so the
|
|
134
|
+
number is read as the floor it is.
|
|
135
|
+
- **It does not cache.** The token lives in the call that earned it and dies
|
|
136
|
+
with it; a second call asks the registry again and gets today's answer.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { RegistryFetch } from "./lib/http";
|
|
2
|
+
export declare const bytesOf: (text: string) => Uint8Array;
|
|
3
|
+
export declare const sha256Of: (text: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* An image config blob. `APP_TOKEN` is here on purpose: it is the shape of the
|
|
6
|
+
* accident this package must not amplify, and the test asserts the value never
|
|
7
|
+
* reaches the output.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONFIG_JSON = "{\"architecture\":\"amd64\",\"os\":\"linux\",\"created\":\"2026-04-02T10:11:12.13Z\",\n \"config\":{\"User\":\"app\",\"WorkingDir\":\"/srv\",\"Entrypoint\":[\"/bin/app\"],\"Cmd\":[\"--serve\"],\n \"Env\":[\"PATH=/usr/local/bin:/usr/bin\",\"APP_TOKEN=s3cr3t-do-not-echo\"],\n \"ExposedPorts\":{\"8080/tcp\":{}},\n \"Labels\":{\"org.opencontainers.image.title\":\"caf\\u00e9-runner\",\"org.opencontainers.image.version\":\"1.4.2\"}},\n \"rootfs\":{\"type\":\"layers\",\"diff_ids\":[\"sha256:1111111111111111111111111111111111111111111111111111111111111111\",\"sha256:2222222222222222222222222222222222222222222222222222222222222222\"]}}";
|
|
10
|
+
export declare const CONFIG_DIGEST: string;
|
|
11
|
+
/** An OCI image manifest, amd64. Note the indentation and the escaped é. */
|
|
12
|
+
export declare const OCI_AMD64_JSON: string;
|
|
13
|
+
export declare const OCI_AMD64_DIGEST: string;
|
|
14
|
+
/** The same image for arm64/v8, with no annotations. */
|
|
15
|
+
export declare const OCI_ARM64_JSON: string;
|
|
16
|
+
export declare const OCI_ARM64_DIGEST: string;
|
|
17
|
+
/**
|
|
18
|
+
* A multi-arch index with a buildkit attestation entry — the `unknown/unknown`
|
|
19
|
+
* descriptor that a naive "first manifest wins" resolver picks and then reports
|
|
20
|
+
* a provenance document as if it were the image.
|
|
21
|
+
*/
|
|
22
|
+
export declare const OCI_INDEX_JSON: string;
|
|
23
|
+
export declare const OCI_INDEX_DIGEST: string;
|
|
24
|
+
/** Docker's own manifest list + v2 manifest, which Hub still serves for older images. */
|
|
25
|
+
export declare const DOCKER_AMD64_JSON: string;
|
|
26
|
+
export declare const DOCKER_AMD64_DIGEST: string;
|
|
27
|
+
export declare const DOCKER_LIST_JSON: string;
|
|
28
|
+
export declare const DOCKER_LIST_DIGEST: string;
|
|
29
|
+
/** A schema 1 manifest — digest over a signature envelope, which is why it is refused. */
|
|
30
|
+
export declare const SCHEMA1_JSON = "{\"schemaVersion\":1,\"name\":\"library/ancient\",\"tag\":\"1.0\",\"architecture\":\"amd64\",\"fsLayers\":[{\"blobSum\":\"sha256:8888888888888888888888888888888888888888888888888888888888888888\"}],\"signatures\":[{\"header\":{\"alg\":\"ES256\"},\"signature\":\"abc\",\"protected\":\"def\"}]}";
|
|
31
|
+
/** An OCI artifact: image-manifest shaped, but its config is a Helm chart. */
|
|
32
|
+
export declare const HELM_MANIFEST_JSON = "{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"size\":141,\"digest\":\"sha256:9999999999999999999999999999999999999999999999999999999999999999\"},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"size\":4096,\"digest\":\"sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999\"}]}";
|
|
33
|
+
export type RegistryFixture = {
|
|
34
|
+
/** The host the client talks to — Hub's is not `docker.io`. */
|
|
35
|
+
readonly apiHost: string;
|
|
36
|
+
/** Exactly what the registry puts on the 401. */
|
|
37
|
+
readonly challenge: (repository: string) => string;
|
|
38
|
+
readonly tokenUrl: string;
|
|
39
|
+
readonly token: string;
|
|
40
|
+
readonly tokenBody: string;
|
|
41
|
+
};
|
|
42
|
+
export declare const DOCKER_HUB: RegistryFixture;
|
|
43
|
+
export declare const GHCR: RegistryFixture;
|
|
44
|
+
export declare const QUAY: RegistryFixture;
|
|
45
|
+
export type RecordedCall = {
|
|
46
|
+
readonly url: string;
|
|
47
|
+
readonly method: string;
|
|
48
|
+
readonly authorization: string | null;
|
|
49
|
+
readonly accept: string | null;
|
|
50
|
+
readonly pinnedIp: string;
|
|
51
|
+
};
|
|
52
|
+
export type StubRoute = {
|
|
53
|
+
/** Exact URL, or a predicate over the parsed URL. */
|
|
54
|
+
readonly match: string | ((url: URL) => boolean);
|
|
55
|
+
readonly respond: (req: Request, url: URL) => Response;
|
|
56
|
+
};
|
|
57
|
+
export type Stub = {
|
|
58
|
+
readonly fetch: RegistryFetch;
|
|
59
|
+
readonly calls: RecordedCall[];
|
|
60
|
+
};
|
|
61
|
+
/** Build a fetch from an ordered route table. First match wins; no match is a 404. */
|
|
62
|
+
export declare function stubFetch(routes: readonly StubRoute[]): Stub;
|
|
63
|
+
export declare function jsonResponse(status: number, body: string, headers?: Record<string, string>): Response;
|
|
64
|
+
export type Served = {
|
|
65
|
+
readonly body: string;
|
|
66
|
+
readonly mediaType: string;
|
|
67
|
+
/**
|
|
68
|
+
* What to put in `Docker-Content-Digest`. Omitted ⇒ the true digest of the
|
|
69
|
+
* bytes; `null` ⇒ no header at all, which some proxies do.
|
|
70
|
+
*/
|
|
71
|
+
readonly contentDigest?: string | null;
|
|
72
|
+
};
|
|
73
|
+
export type TagPage = {
|
|
74
|
+
readonly body: string;
|
|
75
|
+
readonly nextLast?: string;
|
|
76
|
+
readonly link?: string;
|
|
77
|
+
};
|
|
78
|
+
export type RepoContent = {
|
|
79
|
+
/** Keyed by reference: a tag, or a `sha256:…` digest. */
|
|
80
|
+
readonly manifests?: Record<string, Served>;
|
|
81
|
+
readonly blobs?: Record<string, Served>;
|
|
82
|
+
/** Pages in order; page 0 is the request with no `last` parameter. */
|
|
83
|
+
readonly tagPages?: readonly TagPage[];
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Serve one repository from one registry fixture, including the 401 → token →
|
|
87
|
+
* retry dance. Requests without a bearer token get the registry's own
|
|
88
|
+
* challenge, which is what makes these tests exercise the real flow rather than
|
|
89
|
+
* a happy path with the auth step removed.
|
|
90
|
+
*/
|
|
91
|
+
export declare function registryStub(fixture: RegistryFixture, repository: string, content: RepoContent, extraRoutes?: readonly StubRoute[]): Stub;
|
|
92
|
+
/** The standard multi-arch repository, served by whichever registry is passed. */
|
|
93
|
+
export declare function multiArchContent(): RepoContent;
|
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorded registry shapes, and a fetch stub that serves them.
|
|
3
|
+
*
|
|
4
|
+
* These are the three registries this package is meant to work against —
|
|
5
|
+
* Docker Hub, ghcr.io and quay.io — reduced to what actually differs between
|
|
6
|
+
* them: the challenge header, the realm host, whether a service and a scope are
|
|
7
|
+
* echoed, and whether anything else shares the `WWW-Authenticate` line. If the
|
|
8
|
+
* generic challenge-driven flow handles these three it handles the fourth one
|
|
9
|
+
* nobody has tried yet, which is the entire argument for not special-casing
|
|
10
|
+
* Docker Hub.
|
|
11
|
+
*
|
|
12
|
+
* The manifest bytes are deliberately UGLY: three-space indentation, keys in
|
|
13
|
+
* the order a builder emitted them rather than sorted, and a `é` escape
|
|
14
|
+
* instead of a literal é. `JSON.stringify(JSON.parse(bytes))` produces
|
|
15
|
+
* different bytes for every one of them, which is the property the digest tests
|
|
16
|
+
* need — a reserializing implementation passes a fixture whose formatting is
|
|
17
|
+
* already canonical and fails in production.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
export const bytesOf = (text) => encoder.encode(text);
|
|
22
|
+
export const sha256Of = (text) => `sha256:${createHash("sha256").update(encoder.encode(text)).digest("hex")}`;
|
|
23
|
+
// ─── documents ──────────────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* An image config blob. `APP_TOKEN` is here on purpose: it is the shape of the
|
|
26
|
+
* accident this package must not amplify, and the test asserts the value never
|
|
27
|
+
* reaches the output.
|
|
28
|
+
*/
|
|
29
|
+
export const CONFIG_JSON = `{"architecture":"amd64","os":"linux","created":"2026-04-02T10:11:12.13Z",
|
|
30
|
+
"config":{"User":"app","WorkingDir":"/srv","Entrypoint":["/bin/app"],"Cmd":["--serve"],
|
|
31
|
+
"Env":["PATH=/usr/local/bin:/usr/bin","APP_TOKEN=s3cr3t-do-not-echo"],
|
|
32
|
+
"ExposedPorts":{"8080/tcp":{}},
|
|
33
|
+
"Labels":{"org.opencontainers.image.title":"caf\\u00e9-runner","org.opencontainers.image.version":"1.4.2"}},
|
|
34
|
+
"rootfs":{"type":"layers","diff_ids":["sha256:1111111111111111111111111111111111111111111111111111111111111111","sha256:2222222222222222222222222222222222222222222222222222222222222222"]}}`;
|
|
35
|
+
export const CONFIG_DIGEST = sha256Of(CONFIG_JSON);
|
|
36
|
+
/** An OCI image manifest, amd64. Note the indentation and the escaped é. */
|
|
37
|
+
export const OCI_AMD64_JSON = `{ "schemaVersion": 2,
|
|
38
|
+
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
39
|
+
"config": {
|
|
40
|
+
"mediaType": "application/vnd.oci.image.config.v1+json",
|
|
41
|
+
"size": ${CONFIG_JSON.length},
|
|
42
|
+
"digest": "${CONFIG_DIGEST}"
|
|
43
|
+
},
|
|
44
|
+
"layers": [
|
|
45
|
+
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "size": 3401687,
|
|
46
|
+
"digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333" },
|
|
47
|
+
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "size": 128,
|
|
48
|
+
"digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
|
|
49
|
+
],
|
|
50
|
+
"annotations": { "org.opencontainers.image.title": "caf\\u00e9-runner",
|
|
51
|
+
"org.opencontainers.image.revision": "9f3c1ad" }
|
|
52
|
+
}`;
|
|
53
|
+
export const OCI_AMD64_DIGEST = sha256Of(OCI_AMD64_JSON);
|
|
54
|
+
/** The same image for arm64/v8, with no annotations. */
|
|
55
|
+
export const OCI_ARM64_JSON = `{ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
56
|
+
"config": { "mediaType": "application/vnd.oci.image.config.v1+json", "size": ${CONFIG_JSON.length}, "digest": "${CONFIG_DIGEST}" },
|
|
57
|
+
"layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "size": 3211987,
|
|
58
|
+
"digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555" } ]
|
|
59
|
+
}`;
|
|
60
|
+
export const OCI_ARM64_DIGEST = sha256Of(OCI_ARM64_JSON);
|
|
61
|
+
/**
|
|
62
|
+
* A multi-arch index with a buildkit attestation entry — the `unknown/unknown`
|
|
63
|
+
* descriptor that a naive "first manifest wins" resolver picks and then reports
|
|
64
|
+
* a provenance document as if it were the image.
|
|
65
|
+
*/
|
|
66
|
+
export const OCI_INDEX_JSON = `{ "schemaVersion": 2,
|
|
67
|
+
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
68
|
+
"manifests": [
|
|
69
|
+
{ "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": ${OCI_AMD64_JSON.length},
|
|
70
|
+
"digest": "${OCI_AMD64_DIGEST}",
|
|
71
|
+
"platform": { "architecture": "amd64", "os": "linux" } },
|
|
72
|
+
{ "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": ${OCI_ARM64_JSON.length},
|
|
73
|
+
"digest": "${OCI_ARM64_DIGEST}",
|
|
74
|
+
"platform": { "architecture": "arm64", "os": "linux", "variant": "v8" } },
|
|
75
|
+
{ "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": 566,
|
|
76
|
+
"digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
|
|
77
|
+
"platform": { "architecture": "unknown", "os": "unknown" },
|
|
78
|
+
"annotations": { "vnd.docker.reference.type": "attestation-manifest" } }
|
|
79
|
+
]
|
|
80
|
+
}`;
|
|
81
|
+
export const OCI_INDEX_DIGEST = sha256Of(OCI_INDEX_JSON);
|
|
82
|
+
/** Docker's own manifest list + v2 manifest, which Hub still serves for older images. */
|
|
83
|
+
export const DOCKER_AMD64_JSON = `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":${CONFIG_JSON.length},"digest":"${CONFIG_DIGEST}"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":3401687,"digest":"sha256:7777777777777777777777777777777777777777777777777777777777777777"}]}`;
|
|
84
|
+
export const DOCKER_AMD64_DIGEST = sha256Of(DOCKER_AMD64_JSON);
|
|
85
|
+
export const DOCKER_LIST_JSON = `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","size":${DOCKER_AMD64_JSON.length},"digest":"${DOCKER_AMD64_DIGEST}","platform":{"architecture":"amd64","os":"linux"}}]}`;
|
|
86
|
+
export const DOCKER_LIST_DIGEST = sha256Of(DOCKER_LIST_JSON);
|
|
87
|
+
/** A schema 1 manifest — digest over a signature envelope, which is why it is refused. */
|
|
88
|
+
export const SCHEMA1_JSON = `{"schemaVersion":1,"name":"library/ancient","tag":"1.0","architecture":"amd64","fsLayers":[{"blobSum":"sha256:8888888888888888888888888888888888888888888888888888888888888888"}],"signatures":[{"header":{"alg":"ES256"},"signature":"abc","protected":"def"}]}`;
|
|
89
|
+
/** An OCI artifact: image-manifest shaped, but its config is a Helm chart. */
|
|
90
|
+
export const HELM_MANIFEST_JSON = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.cncf.helm.config.v1+json","size":141,"digest":"sha256:9999999999999999999999999999999999999999999999999999999999999999"},"layers":[{"mediaType":"application/vnd.cncf.helm.chart.content.v1.tar+gzip","size":4096,"digest":"sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999"}]}`;
|
|
91
|
+
export const DOCKER_HUB = {
|
|
92
|
+
apiHost: "registry-1.docker.io",
|
|
93
|
+
// Hub omits the scope on the /v2/ probe and includes it on a manifest 401.
|
|
94
|
+
challenge: (repository) => `Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:${repository}:pull"`,
|
|
95
|
+
tokenUrl: "https://auth.docker.io/token",
|
|
96
|
+
token: "hub-token",
|
|
97
|
+
tokenBody: `{"token":"hub-token","access_token":"hub-token","expires_in":300,"issued_at":"2026-09-18T09:00:00.000000000Z"}`,
|
|
98
|
+
};
|
|
99
|
+
export const GHCR = {
|
|
100
|
+
apiHost: "ghcr.io",
|
|
101
|
+
challenge: (repository) => `Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:${repository}:pull"`,
|
|
102
|
+
tokenUrl: "https://ghcr.io/token",
|
|
103
|
+
token: "ghcr-token",
|
|
104
|
+
tokenBody: `{"token":"ghcr-token"}`,
|
|
105
|
+
};
|
|
106
|
+
export const QUAY = {
|
|
107
|
+
apiHost: "quay.io",
|
|
108
|
+
// quay has shipped two challenges on one line; the Bearer one is second.
|
|
109
|
+
challenge: (repository) => `Basic realm="quay.io", Bearer realm="https://quay.io/v2/auth",service="quay.io",scope="repository:${repository}:pull"`,
|
|
110
|
+
tokenUrl: "https://quay.io/v2/auth",
|
|
111
|
+
token: "quay-token",
|
|
112
|
+
tokenBody: `{"token":"quay-token","expires_in":600,"issued_at":"2026-09-18T09:00:00Z"}`,
|
|
113
|
+
};
|
|
114
|
+
/** Build a fetch from an ordered route table. First match wins; no match is a 404. */
|
|
115
|
+
export function stubFetch(routes) {
|
|
116
|
+
const calls = [];
|
|
117
|
+
const fetch = async (req, pinnedIp) => {
|
|
118
|
+
const url = new URL(req.url);
|
|
119
|
+
calls.push({
|
|
120
|
+
url: req.url,
|
|
121
|
+
method: req.method,
|
|
122
|
+
authorization: req.headers.get("authorization"),
|
|
123
|
+
accept: req.headers.get("accept"),
|
|
124
|
+
pinnedIp,
|
|
125
|
+
});
|
|
126
|
+
for (const route of routes) {
|
|
127
|
+
const hit = typeof route.match === "string" ? req.url === route.match : route.match(url);
|
|
128
|
+
if (hit)
|
|
129
|
+
return route.respond(req, url);
|
|
130
|
+
}
|
|
131
|
+
return jsonResponse(404, `{"errors":[{"code":"NOT_FOUND","message":"no stub route for ${url.pathname}"}]}`);
|
|
132
|
+
};
|
|
133
|
+
return { fetch, calls };
|
|
134
|
+
}
|
|
135
|
+
export function jsonResponse(status, body, headers = {}) {
|
|
136
|
+
return new Response(bytesOf(body), {
|
|
137
|
+
status,
|
|
138
|
+
headers: { "content-type": "application/json", ...headers },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Serve one repository from one registry fixture, including the 401 → token →
|
|
143
|
+
* retry dance. Requests without a bearer token get the registry's own
|
|
144
|
+
* challenge, which is what makes these tests exercise the real flow rather than
|
|
145
|
+
* a happy path with the auth step removed.
|
|
146
|
+
*/
|
|
147
|
+
export function registryStub(fixture, repository, content, extraRoutes = []) {
|
|
148
|
+
const base = `https://${fixture.apiHost}/v2/${repository}`;
|
|
149
|
+
const authorized = (req) => req.headers.get("authorization") === `Bearer ${fixture.token}`;
|
|
150
|
+
const unauthorized = () => jsonResponse(401, `{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}`, {
|
|
151
|
+
"www-authenticate": fixture.challenge(repository),
|
|
152
|
+
});
|
|
153
|
+
const serve = (served) => {
|
|
154
|
+
const headers = { "content-type": served.mediaType };
|
|
155
|
+
const digest = served.contentDigest === undefined ? sha256Of(served.body) : served.contentDigest;
|
|
156
|
+
if (digest !== null)
|
|
157
|
+
headers["docker-content-digest"] = digest;
|
|
158
|
+
return new Response(bytesOf(served.body), { status: 200, headers });
|
|
159
|
+
};
|
|
160
|
+
const routes = [
|
|
161
|
+
// Overrides first: a test that wants one path to redirect or fail says so
|
|
162
|
+
// without having to restate the rest of the registry.
|
|
163
|
+
...extraRoutes,
|
|
164
|
+
{
|
|
165
|
+
match: (url) => `${url.origin}${url.pathname}` === fixture.tokenUrl,
|
|
166
|
+
respond: () => jsonResponse(200, fixture.tokenBody),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
match: (url) => `${url.origin}${url.pathname}`.startsWith(`${base}/manifests/`),
|
|
170
|
+
respond: (req, url) => {
|
|
171
|
+
if (!authorized(req))
|
|
172
|
+
return unauthorized();
|
|
173
|
+
const reference = decodeURIComponent(url.pathname.split("/manifests/")[1] ?? "");
|
|
174
|
+
const served = content.manifests?.[reference];
|
|
175
|
+
if (served === undefined) {
|
|
176
|
+
return jsonResponse(404, `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown: ${reference}"}]}`);
|
|
177
|
+
}
|
|
178
|
+
return serve(served);
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
match: (url) => `${url.origin}${url.pathname}`.startsWith(`${base}/blobs/`),
|
|
183
|
+
respond: (req, url) => {
|
|
184
|
+
if (!authorized(req))
|
|
185
|
+
return unauthorized();
|
|
186
|
+
const digest = url.pathname.split("/blobs/")[1] ?? "";
|
|
187
|
+
const served = content.blobs?.[digest];
|
|
188
|
+
if (served === undefined) {
|
|
189
|
+
return jsonResponse(404, `{"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown"}]}`);
|
|
190
|
+
}
|
|
191
|
+
return serve(served);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
match: (url) => `${url.origin}${url.pathname}` === `${base}/tags/list`,
|
|
196
|
+
respond: (req, url) => {
|
|
197
|
+
if (!authorized(req))
|
|
198
|
+
return unauthorized();
|
|
199
|
+
const pages = content.tagPages ?? [];
|
|
200
|
+
const last = url.searchParams.get("last");
|
|
201
|
+
const index = last === null ? 0 : pages.findIndex((p) => p.nextLast === last) + 1;
|
|
202
|
+
const page = pages[index];
|
|
203
|
+
if (page === undefined)
|
|
204
|
+
return jsonResponse(404, `{"errors":[{"code":"NOT_FOUND"}]}`);
|
|
205
|
+
const headers = {};
|
|
206
|
+
const n = url.searchParams.get("n") ?? "100";
|
|
207
|
+
// Registries send a RELATIVE next link; resolving it against the page
|
|
208
|
+
// URL is part of what the client has to get right.
|
|
209
|
+
const link = page.link ??
|
|
210
|
+
(page.nextLast === undefined
|
|
211
|
+
? undefined
|
|
212
|
+
: `</v2/${repository}/tags/list?n=${n}&last=${page.nextLast}>; rel="next"`);
|
|
213
|
+
if (link !== undefined)
|
|
214
|
+
headers["link"] = link;
|
|
215
|
+
return jsonResponse(200, page.body, headers);
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
return stubFetch(routes);
|
|
220
|
+
}
|
|
221
|
+
/** The standard multi-arch repository, served by whichever registry is passed. */
|
|
222
|
+
export function multiArchContent() {
|
|
223
|
+
return {
|
|
224
|
+
manifests: {
|
|
225
|
+
"1.4.2": { body: OCI_INDEX_JSON, mediaType: "application/vnd.oci.image.index.v1+json" },
|
|
226
|
+
latest: { body: OCI_INDEX_JSON, mediaType: "application/vnd.oci.image.index.v1+json" },
|
|
227
|
+
[OCI_INDEX_DIGEST]: {
|
|
228
|
+
body: OCI_INDEX_JSON,
|
|
229
|
+
mediaType: "application/vnd.oci.image.index.v1+json",
|
|
230
|
+
},
|
|
231
|
+
[OCI_AMD64_DIGEST]: {
|
|
232
|
+
body: OCI_AMD64_JSON,
|
|
233
|
+
mediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
234
|
+
},
|
|
235
|
+
[OCI_ARM64_DIGEST]: {
|
|
236
|
+
body: OCI_ARM64_JSON,
|
|
237
|
+
mediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
blobs: {
|
|
241
|
+
[CONFIG_DIGEST]: { body: CONFIG_JSON, mediaType: "application/vnd.oci.image.config.v1+json" },
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
2
|
+
import { ManifestError } from "./lib/manifest";
|
|
3
|
+
export { _setFetch, type RegistryFetch } from "./lib/http";
|
|
4
|
+
export { RegistryError } from "./lib/registry";
|
|
5
|
+
export { ImageRefError } from "./lib/ref";
|
|
6
|
+
export { ManifestError };
|
|
7
|
+
export declare const containerImageInspect: RegisteredTool;
|
|
8
|
+
export declare const containerImageTags: RegisteredTool;
|
|
9
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
10
|
+
export declare const CONTAINER_TOOLS: ReadonlyArray<RegisteredTool>;
|