283 lines
9.4 KiB
TypeScript
283 lines
9.4 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
assertSha256Digest,
|
|
type Sha256Digest,
|
|
} from "../shared/build-protocol";
|
|
|
|
const ACCEPT = [
|
|
"application/vnd.oci.image.index.v1+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
].join(", ");
|
|
const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
|
|
const MANIFEST_MEDIA_TYPES = new Set([
|
|
"application/vnd.oci.image.index.v1+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
]);
|
|
|
|
export type RegistryCredentials = { username: string; password: string };
|
|
export type RegistryFetch = (
|
|
input: string | URL | Request,
|
|
init?: RequestInit,
|
|
) => Promise<Response>;
|
|
export type RegistryResolveOptions = {
|
|
fetch?: RegistryFetch;
|
|
credentials?: RegistryCredentials;
|
|
insecure?: boolean;
|
|
origin?: string;
|
|
cacheTtlMs?: number;
|
|
cacheMaxEntries?: number;
|
|
clock?: () => number;
|
|
};
|
|
|
|
const DEFAULT_DIGEST_CACHE_TTL_MS = 30_000;
|
|
const DEFAULT_DIGEST_CACHE_MAX_ENTRIES = 256;
|
|
const digestCache = new Map<
|
|
string,
|
|
{ digest: Sha256Digest; expiresAt: number }
|
|
>();
|
|
|
|
export type ParsedImageReference = {
|
|
registry: string;
|
|
repository: string;
|
|
reference: string;
|
|
digest?: Sha256Digest;
|
|
};
|
|
|
|
function validateRepository(repository: string, image: string): void {
|
|
if (
|
|
!repository ||
|
|
repository
|
|
.split("/")
|
|
.some((part) => !/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/.test(part))
|
|
)
|
|
throw new Error(`Invalid image reference: ${image}`);
|
|
}
|
|
|
|
export function parseImageReference(image: string): ParsedImageReference {
|
|
const slash = image.indexOf("/");
|
|
if (slash <= 0)
|
|
throw new Error("Image reference must include a registry host");
|
|
const registry = image.slice(0, slash);
|
|
let repositoryAndReference = image.slice(slash + 1);
|
|
if (
|
|
!repositoryAndReference ||
|
|
!registry ||
|
|
/[/?#@]/.test(registry) ||
|
|
/\s/.test(image)
|
|
)
|
|
throw new Error(`Invalid image reference: ${image}`);
|
|
|
|
const at = repositoryAndReference.lastIndexOf("@");
|
|
let digest: Sha256Digest | undefined;
|
|
if (at !== -1) {
|
|
const value = repositoryAndReference.slice(at + 1);
|
|
assertSha256Digest(value);
|
|
digest = value;
|
|
repositoryAndReference = repositoryAndReference.slice(0, at);
|
|
}
|
|
const lastSlash = repositoryAndReference.lastIndexOf("/");
|
|
const colon = repositoryAndReference.lastIndexOf(":");
|
|
const tag =
|
|
colon > lastSlash ? repositoryAndReference.slice(colon + 1) : undefined;
|
|
const repository =
|
|
tag !== undefined
|
|
? repositoryAndReference.slice(0, colon)
|
|
: repositoryAndReference;
|
|
validateRepository(repository, image);
|
|
if (tag !== undefined && !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag))
|
|
throw new Error(`Invalid image reference: ${image}`);
|
|
if (digest) return { registry, repository, reference: digest, digest };
|
|
return { registry, repository, reference: tag ?? "latest" };
|
|
}
|
|
|
|
function bearerParameters(
|
|
challenge: string,
|
|
): Record<string, string> | undefined {
|
|
const match = /^Bearer\s+(.+)$/i.exec(challenge.trim());
|
|
if (!match?.[1]) return;
|
|
const values: Record<string, string> = {};
|
|
const expression = /([a-z][a-z0-9_-]*)=(?:"((?:\\.|[^"])*)"|([^,\s]+))/gi;
|
|
for (const item of match[1].matchAll(expression))
|
|
values[item[1]!.toLowerCase()] = (item[2] ?? item[3] ?? "").replace(
|
|
/\\"/g,
|
|
'"',
|
|
);
|
|
return values.realm ? values : undefined;
|
|
}
|
|
|
|
async function responseError(response: Response): Promise<Error> {
|
|
await response.body?.cancel().catch(() => {});
|
|
return new Error(`Registry request failed (${response.status})`);
|
|
}
|
|
|
|
async function readManifestBody(response: Response): Promise<Uint8Array> {
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error("Registry returned an invalid manifest");
|
|
const chunks: Uint8Array[] = [];
|
|
let length = 0;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
length += value.byteLength;
|
|
if (length > MAX_MANIFEST_BYTES) {
|
|
await reader.cancel();
|
|
throw new Error("Registry manifest exceeds the size limit");
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
const body = new Uint8Array(length);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
body.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
let manifest: unknown;
|
|
try {
|
|
manifest = JSON.parse(
|
|
new TextDecoder("utf-8", { fatal: true }).decode(body),
|
|
);
|
|
} catch {
|
|
throw new Error("Registry returned an invalid manifest");
|
|
}
|
|
if (
|
|
!manifest ||
|
|
typeof manifest !== "object" ||
|
|
Array.isArray(manifest) ||
|
|
(manifest as { schemaVersion?: unknown }).schemaVersion !== 2
|
|
)
|
|
throw new Error("Registry returned an invalid manifest");
|
|
const record = manifest as Record<string, unknown>;
|
|
const mediaType = record.mediaType;
|
|
if (typeof mediaType !== "string" || !MANIFEST_MEDIA_TYPES.has(mediaType))
|
|
throw new Error("Registry returned an invalid manifest");
|
|
const isIndex =
|
|
mediaType.endsWith("image.index.v1+json") ||
|
|
mediaType.endsWith("manifest.list.v2+json");
|
|
if (
|
|
isIndex
|
|
? !Array.isArray(record.manifests)
|
|
: !record.config ||
|
|
typeof record.config !== "object" ||
|
|
Array.isArray(record.config) ||
|
|
!Array.isArray(record.layers)
|
|
)
|
|
throw new Error("Registry returned an invalid manifest");
|
|
return body;
|
|
}
|
|
|
|
export async function resolveRegistryDigest(
|
|
image: string,
|
|
options: RegistryResolveOptions = {},
|
|
): Promise<Sha256Digest> {
|
|
const parsed = parseImageReference(image);
|
|
if (parsed.digest) return parsed.digest;
|
|
const now = options.clock ?? Date.now;
|
|
const cacheTtlMs = Number.isFinite(options.cacheTtlMs)
|
|
? Math.max(0, options.cacheTtlMs!)
|
|
: DEFAULT_DIGEST_CACHE_TTL_MS;
|
|
const cacheMaxEntries = Number.isFinite(options.cacheMaxEntries)
|
|
? Math.max(0, Math.floor(options.cacheMaxEntries!))
|
|
: DEFAULT_DIGEST_CACHE_MAX_ENTRIES;
|
|
const fetcher: RegistryFetch = options.fetch ?? globalThis.fetch;
|
|
const scheme = options.insecure ? "http" : "https";
|
|
const repository = parsed.repository
|
|
.split("/")
|
|
.map(encodeURIComponent)
|
|
.join("/");
|
|
const origin = (options.origin ?? `${scheme}://${parsed.registry}`).replace(
|
|
/\/+$/,
|
|
"",
|
|
);
|
|
const credentialsKey = options.credentials
|
|
? createHash("sha256")
|
|
.update(
|
|
`${options.credentials.username}\0${options.credentials.password}`,
|
|
)
|
|
.digest("hex")
|
|
: "anonymous";
|
|
const cacheKey = `${origin}\0${parsed.repository}\0${parsed.reference}\0${credentialsKey}`;
|
|
const cached = digestCache.get(cacheKey);
|
|
if (cached) {
|
|
if (cached.expiresAt > now()) return cached.digest;
|
|
digestCache.delete(cacheKey);
|
|
}
|
|
const manifestUrl = `${origin}/v2/${repository}/manifests/${encodeURIComponent(parsed.reference)}`;
|
|
const headers = new Headers({ accept: ACCEPT });
|
|
if (options.credentials) {
|
|
headers.set(
|
|
"authorization",
|
|
`Basic ${Buffer.from(`${options.credentials.username}:${options.credentials.password}`).toString("base64")}`,
|
|
);
|
|
}
|
|
|
|
let response = await fetcher(manifestUrl, { headers });
|
|
if (response.status === 401) {
|
|
const challenge = bearerParameters(
|
|
response.headers.get("www-authenticate") ?? "",
|
|
);
|
|
if (!challenge) throw await responseError(response);
|
|
const tokenUrl = new URL(challenge.realm!);
|
|
if (tokenUrl.protocol !== "https:" && !options.insecure)
|
|
throw new Error("Registry bearer token realm must use HTTPS");
|
|
if (tokenUrl.protocol !== "https:" && tokenUrl.protocol !== "http:")
|
|
throw new Error("Registry bearer token realm must use HTTP or HTTPS");
|
|
if (challenge.service)
|
|
tokenUrl.searchParams.set("service", challenge.service);
|
|
tokenUrl.searchParams.set(
|
|
"scope",
|
|
challenge.scope ?? `repository:${parsed.repository}:pull`,
|
|
);
|
|
const tokenHeaders = new Headers();
|
|
if (options.credentials)
|
|
tokenHeaders.set(
|
|
"authorization",
|
|
`Basic ${Buffer.from(`${options.credentials.username}:${options.credentials.password}`).toString("base64")}`,
|
|
);
|
|
const tokenResponse = await fetcher(tokenUrl, { headers: tokenHeaders });
|
|
if (!tokenResponse.ok) throw await responseError(tokenResponse);
|
|
const payload = (await tokenResponse.json()) as {
|
|
token?: unknown;
|
|
access_token?: unknown;
|
|
};
|
|
const token = payload.token ?? payload.access_token;
|
|
if (typeof token !== "string" || !token)
|
|
throw new Error("Registry token response did not contain a token");
|
|
headers.set("authorization", `Bearer ${token}`);
|
|
response = await fetcher(manifestUrl, { headers });
|
|
}
|
|
if (!response.ok) throw await responseError(response);
|
|
|
|
const body = await readManifestBody(response);
|
|
const advertised = response.headers
|
|
.get("docker-content-digest")
|
|
?.trim()
|
|
?.toLowerCase();
|
|
const digest =
|
|
`sha256:${createHash("sha256").update(body).digest("hex")}` as Sha256Digest;
|
|
if (advertised !== undefined) {
|
|
assertSha256Digest(advertised);
|
|
if (advertised !== digest)
|
|
throw new Error(
|
|
"Registry manifest digest does not match Docker-Content-Digest",
|
|
);
|
|
}
|
|
if (cacheTtlMs && cacheMaxEntries) {
|
|
digestCache.delete(cacheKey);
|
|
while (digestCache.size >= cacheMaxEntries)
|
|
digestCache.delete(digestCache.keys().next().value!);
|
|
digestCache.set(cacheKey, {
|
|
digest,
|
|
expiresAt: now() + cacheTtlMs,
|
|
});
|
|
}
|
|
return digest;
|
|
}
|