import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { sha256 } from "./release-bundle.mjs"; const accept = "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; export async function boundedFetch(url, options = {}, timeout = 30_000) { try { return await fetch(url, { ...options, redirect: options.redirect ?? "error", signal: AbortSignal.timeout(timeout) }); } catch { throw new Error("Release network request failed; retry with the same output directory."); } } export async function readLimited(response, maximum) { const chunks = []; let size = 0; for await (const chunk of response.body) { size += chunk.length; if (size > maximum) throw new Error("Release response exceeded its bound."); chunks.push(chunk); } return Buffer.concat(chunks); } async function jsonResponse(response, description) { if (!response.ok) throw new Error(`${description} refused (HTTP ${response.status}).`); const bytes = await readLimited(response, 4 * 2 ** 20); try { return { bytes, value: JSON.parse(bytes.toString()) }; } catch { throw new Error("Release service returned invalid metadata."); } } export async function dockerCredentials(run) { const config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), ".docker"), "config.json"), "utf8")); const server = "https://index.docker.io/v1/"; const helper = config.credHelpers?.[server] || config.credsStore; if (!helper || !/^[A-Za-z0-9._-]+$/.test(helper)) throw new Error("Use docker login with an OS credential store before publishing."); const auth = JSON.parse(await run(`docker-credential-${helper}`, ["get"], { input: server + "\n", quiet: true })); if (!auth.Username || !auth.Secret) throw new Error("Docker Hub login is unavailable."); return auth; } export function dockerHub(auth) { async function token(repository, anonymous) { const url = new URL("https://auth.docker.io/token"); url.searchParams.set("service", "registry.docker.io"); url.searchParams.set("scope", `repository:${repository}:pull`); const response = await boundedFetch(url, { headers: anonymous ? {} : { Authorization: `Basic ${Buffer.from(`${auth.Username}:${auth.Secret}`).toString("base64")}` } }); return (await jsonResponse(response, "Registry authentication")).value.token; } async function registryJSON(repository, route, anonymous, allowMissing = false) { const bearer = await token(repository, anonymous); let response = await boundedFetch(`https://registry-1.docker.io/v2/${repository}/${route}`, { redirect: "manual", headers: { Accept: accept, Authorization: `Bearer ${bearer}` } }); if ([302, 307].includes(response.status) && route.startsWith("blobs/")) { const target = new URL(response.headers.get("location")); if (target.protocol !== "https:" || target.username || target.password) throw new Error("Invalid registry blob redirect."); // Signed blob URLs are fetched without forwarding registry credentials. response = await boundedFetch(target); } if (allowMissing && response.status === 404) return null; const { bytes, value } = await jsonResponse(response, "Registry read"); const digest = `sha256:${sha256(bytes)}`; const advertised = response.headers.get("docker-content-digest"); if (advertised && advertised !== digest) throw new Error("Registry content digest mismatch."); return { value, digest }; } return { async ensurePublic(namespace, name) { const response = await boundedFetch("https://hub.docker.com/v2/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: auth.Username, secret: auth.Secret }) }); const bearer = (await jsonResponse(response, "Docker Hub authentication")).value.access_token; const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" }; const path = `https://hub.docker.com/v2/namespaces/${namespace}/repositories`; let existing = await boundedFetch(`${path}/${name}`, { headers }); if (existing.status === 404) { existing = await boundedFetch(path, { method: "POST", headers, body: JSON.stringify({ namespace, name, registry: "docker.io", is_private: false, description: `ThothII ${name.endsWith("core") ? "core with embedded Pi" : "standalone frontend"}` }) }); } const data = (await jsonResponse(existing, "Public repository preparation")).value; if (data.is_private !== false) throw new Error("Selected Docker Hub repository is private; make this release repository public before retrying."); }, async inspect(repository, reference, platform, { anonymous = false, allowMissing = false } = {}) { let image = await registryJSON(repository, `manifests/${reference}`, anonymous, allowMissing); if (!image) return null; if (reference.startsWith("sha256:") && image.digest !== reference) throw new Error("Requested image digest does not match registry content."); if (image.value.manifests) { const match = image.value.manifests.find((entry) => `${entry.platform?.os}/${entry.platform?.architecture}` === platform); if (!match || !/^sha256:[a-f0-9]{64}$/.test(match.digest)) throw new Error("Image does not contain the requested platform."); image = await registryJSON(repository, `manifests/${match.digest}`, anonymous); if (image.digest !== match.digest) throw new Error("Image index digest mismatch."); } if (reference.startsWith("sha256:") && !image.value.config) throw new Error("Image metadata is incomplete."); const configDigest = image.value.config?.digest; if (!/^sha256:[a-f0-9]{64}$/.test(configDigest ?? "")) throw new Error("Image configuration digest is invalid."); const config = await registryJSON(repository, `blobs/${configDigest}`, anonymous); if (config.digest !== configDigest || `${config.value.os}/${config.value.architecture}` !== platform) throw new Error("Image configuration or platform mismatch."); return { reference: `docker.io/${repository}@${image.digest}`, labels: config.value.config?.Labels ?? {} }; }, }; }