src/federation/oci.rs
//! **OCI Distribution** harbour — GitHub Container Registry (GHCR), Harbor, Quay,
//! Docker Hub, and any registry that speaks the **OCI Distribution Spec v2**
//! (`/v2/<name>/manifests/<ref>`, `/v2/<name>/blobs/<digest>`).
//!
//! This is a genuinely different protocol from the file-path harbours
//! (Artifactory/Nexus/Azure/AWS): an OCI registry is a **content-addressed image
//! store** — a manifest points at digest-named blobs. So this harbour resolves an
//! [`ArtifactCoord`] as `name = <repository>` (e.g. `nordisk/holger`, may contain
//! `/`) and `version = <reference>` (a tag like `1.2` or a `sha256:…` digest):
//! [`fetch`](OciDistributionRegistry::fetch) returns the **manifest** bytes,
//! [`metadata`](OciDistributionRegistry::metadata) HEADs it (size +
//! `Docker-Content-Digest`), and [`fetch_blob`](OciDistributionRegistry::fetch_blob)
//! pulls a config/layer blob and VERIFIES it content-addresses to its digest (a
//! strong, protocol-native integrity guarantee — a tampered blob is a hard error).
//!
//! Auth follows the module's out-of-band-token philosophy (same as Azure/AWS): the
//! caller supplies a **Bearer** token minted by `gh auth token` / `docker login` /
//! the registry's anonymous `GET /token?scope=repository:<repo>:pull` endpoint (for
//! GHCR, the base64-encoded PAT), or **Basic**. No `WWW-Authenticate` auto-handshake
//! and no crypto is hand-rolled here.
#[cfg(feature = "federation")]
use std::io::Read;
use anyhow::{Result, anyhow};
#[cfg(feature = "federation")]
use anyhow::bail;
use super::{ArtifactCoord, UpstreamAuth};
#[cfg(feature = "federation")]
use super::{ArtifactMeta, MAX_DOWNLOAD_BYTES, RegistryFormat, UpstreamRegistry, verify_sha256};
#[cfg(feature = "federation")]
use super::{Request, Response, agent, apply_auth, header, status_err};
/// The `Accept` header advertising the manifest media types an OCI client
/// understands, so the registry returns an image manifest / index (not an error or a
/// legacy schema-1 manifest).
#[cfg(feature = "federation")]
const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, \
application/vnd.oci.image.index.v1+json, \
application/vnd.docker.distribution.manifest.v2+json, \
application/vnd.docker.distribution.manifest.list.v2+json";
/// A parsed `WWW-Authenticate: Bearer …` challenge — the token-endpoint an OCI
/// registry directs a client to on a 401. `realm` (the token URL) is required;
/// `service`/`scope` are the query parameters the token request must echo.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(not(feature = "federation"), allow(dead_code))]
pub struct BearerChallenge {
pub realm: String,
pub service: Option<String>,
pub scope: Option<String>,
}
/// Parse a `WWW-Authenticate` header value into a [`BearerChallenge`]. Accepts the
/// OCI/Docker form `Bearer realm="…",service="…",scope="…"` (case-insensitive
/// scheme, quoted values, any pair order). A non-Bearer scheme or a missing `realm`
/// is a hard `Err` (a malformed challenge must not silently degrade to anonymous).
#[cfg_attr(not(feature = "federation"), allow(dead_code))]
pub fn parse_bearer_challenge(header: &str) -> Result<BearerChallenge> {
let rest = header
.trim()
.strip_prefix("Bearer ")
.or_else(|| header.trim().strip_prefix("bearer "))
.ok_or_else(|| anyhow!("not a Bearer challenge: {header:?}"))?;
let (mut realm, mut service, mut scope) = (None, None, None);
// Params are comma-separated `key="value"` pairs; realm/service/scope values do
// not contain commas, so a plain split is sufficient.
for part in rest.split(',') {
if let Some((k, v)) = part.split_once('=') {
let v = v.trim().trim_matches('"').to_string();
match k.trim().to_ascii_lowercase().as_str() {
"realm" => realm = Some(v),
"service" => service = Some(v),
"scope" => scope = Some(v),
_ => {}
}
}
}
let realm = realm.ok_or_else(|| anyhow!("Bearer challenge missing realm: {header:?}"))?;
Ok(BearerChallenge {
realm,
service,
scope,
})
}
/// A read-only harbour proxying an **OCI Distribution v2** registry.
#[cfg_attr(not(feature = "federation"), allow(dead_code))]
pub struct OciDistributionRegistry {
name: String,
/// Registry base, e.g. `https://ghcr.io` (no trailing slash).
base_url: String,
auth: UpstreamAuth,
}
impl OciDistributionRegistry {
pub fn new(name: impl Into<String>, base_url: impl Into<String>, auth: UpstreamAuth) -> Self {
Self {
name: name.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
auth,
}
}
/// GitHub Container Registry (`https://ghcr.io`). The bearer token is a
/// GHCR-accepted token minted out-of-band (`gh auth token`, or the base64-encoded
/// PAT — GHCR accepts `Authorization: Bearer <base64(PAT)>` on `/v2/`).
pub fn ghcr(auth: UpstreamAuth) -> Self {
Self::new("ghcr", "https://ghcr.io", auth)
}
/// A self-hosted Harbor (or any registry-2) at `base_url`.
pub fn harbor(base_url: impl Into<String>, auth: UpstreamAuth) -> Self {
Self::new("harbor", base_url, auth)
}
/// Manifest URL for `id`: `{base}/v2/{repository}/manifests/{reference}` where
/// `repository = id.name` and `reference = id.version` (a tag or `sha256:…`).
pub fn manifest_url(&self, id: &ArtifactCoord) -> String {
format!("{}/v2/{}/manifests/{}", self.base_url, id.name, id.version)
}
/// Blob URL: `{base}/v2/{repository}/blobs/{digest}` (digest = `sha256:…`).
pub fn blob_url(&self, repository: &str, digest: &str) -> String {
format!("{}/v2/{}/blobs/{}", self.base_url, repository, digest)
}
/// Fetch a **blob** (image config or a layer) BY DIGEST and VERIFY it
/// content-addresses to that digest. OCI blobs are content-addressed, so this is
/// a protocol-native integrity guarantee: a body that does not hash to `digest`
/// is a hard `Err` (a corrupt/poisoned layer), not silently returned. `Ok(None)`
/// on a clean upstream 404. Auto-negotiates a Bearer token on a 401 challenge.
#[cfg(feature = "federation")]
pub fn fetch_blob(&self, repository: &str, digest: &str) -> Result<Option<Vec<u8>>> {
let url = self.blob_url(repository, digest);
let build = |auth: &UpstreamAuth| apply_auth(&self.base_url, &url, auth, agent().get(&url));
match self.call_negotiated(&url, build)? {
Some(resp) => {
let mut buf = Vec::new();
resp.into_body()
.into_reader()
.take(MAX_DOWNLOAD_BYTES)
.read_to_end(&mut buf)
.map_err(|e| anyhow!("read blob {url}: {e}"))?;
verify_sha256(&buf, digest)?;
Ok(Some(buf))
}
None => Ok(None),
}
}
/// GET the manifest bytes for `id`, sending the OCI `Accept` header. `Ok(None)`
/// on a clean 404. Body capped at [`MAX_DOWNLOAD_BYTES`]. Auto-negotiates a
/// Bearer token on a 401 challenge.
#[cfg(feature = "federation")]
fn get_manifest(&self, id: &ArtifactCoord) -> Result<Option<Vec<u8>>> {
let url = self.manifest_url(id);
let build = |auth: &UpstreamAuth| {
apply_auth(&self.base_url, &url, auth, agent().get(&url)).header("Accept", MANIFEST_ACCEPT)
};
match self.call_negotiated(&url, build)? {
Some(resp) => {
let mut buf = Vec::new();
resp.into_body()
.into_reader()
.take(MAX_DOWNLOAD_BYTES)
.read_to_end(&mut buf)
.map_err(|e| anyhow!("read manifest {url}: {e}"))?;
Ok(Some(buf))
}
None => Ok(None),
}
}
/// Run a request through the OCI **token auto-handshake**: call it with the
/// harbour's configured auth; if the registry answers `401` with a
/// `WWW-Authenticate: Bearer` challenge, parse it, mint a token at the token
/// endpoint, and retry ONCE with `Authorization: Bearer <token>`. `build` makes a
/// fresh request for a given auth (so it can be issued twice). `Ok(None)` on a
/// clean 404; a 401 without a parseable Bearer challenge is a hard `Err`.
#[cfg(feature = "federation")]
fn call_negotiated<B>(&self, url: &str, build: B) -> Result<Option<Response>>
where
B: Fn(&UpstreamAuth) -> Request,
{
let resp = build(&self.auth).call().map_err(|e| anyhow!("upstream {url}: {e}"))?;
match status_err(&resp) {
None => Ok(Some(resp)),
Some(401) => {
let www = header(&resp, "WWW-Authenticate").ok_or_else(|| {
anyhow!("upstream {url}: 401 with no WWW-Authenticate challenge")
})?;
let challenge = parse_bearer_challenge(&www)?;
let token = self.fetch_token(&challenge)?;
let bearer = UpstreamAuth::Bearer { token };
let resp = build(&bearer).call().map_err(|e| anyhow!("upstream {url}: {e}"))?;
match status_err(&resp) {
None => Ok(Some(resp)),
Some(404) => Ok(None),
Some(code) => bail!("upstream {url}: HTTP {code} after token auth"),
}
}
Some(404) => Ok(None),
Some(code) => bail!("upstream {url}: HTTP {code}"),
}
}
/// Mint a registry token from a parsed [`BearerChallenge`]: `GET <realm>?service=
/// …&scope=…`, carrying the harbour's own credentials (Basic/Bearer) to the token
/// endpoint (its own origin — a private-repo login), and read the `token` (or
/// `access_token`) field from the JSON response. The registry token is minted
/// here from operator-supplied creds; no crypto is hand-rolled.
#[cfg(feature = "federation")]
fn fetch_token(&self, challenge: &BearerChallenge) -> Result<String> {
let mut url = challenge.realm.clone();
let mut qs: Vec<String> = Vec::new();
if let Some(s) = &challenge.service {
qs.push(format!("service={s}"));
}
if let Some(s) = &challenge.scope {
qs.push(format!("scope={s}"));
}
if !qs.is_empty() {
url.push('?');
url.push_str(&qs.join("&"));
}
// Auth the token request against the realm's OWN origin (it may differ from
// the registry host, e.g. auth.docker.io), so pass the realm URL as base.
let resp = apply_auth(&url, &url, &self.auth, agent().get(&url))
.call()
.map_err(|e| anyhow!("token endpoint {url}: {e}"))?;
if let Some(code) = status_err(&resp) {
bail!("token endpoint {url}: http status: {code}");
}
let body = resp
.into_body()
.read_to_string()
.map_err(|e| anyhow!("token endpoint {url}: read body: {e}"))?;
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| anyhow!("token endpoint {url}: non-JSON response: {e}"))?;
v.get("token")
.or_else(|| v.get("access_token"))
.and_then(|t| t.as_str())
.map(str::to_string)
.ok_or_else(|| anyhow!("token endpoint {url}: response has no `token`/`access_token`"))
}
}
#[cfg(feature = "federation")]
impl UpstreamRegistry for OciDistributionRegistry {
fn name(&self) -> &str {
&self.name
}
fn base_url(&self) -> &str {
&self.base_url
}
fn auth(&self) -> &UpstreamAuth {
&self.auth
}
fn format(&self) -> RegistryFormat {
RegistryFormat::Oci
}
/// Fetch the image **manifest** bytes for `id` (`name = repository`,
/// `version = tag|digest`).
fn fetch(&self, id: &ArtifactCoord) -> Result<Option<Vec<u8>>> {
self.get_manifest(id)
}
/// HEAD the manifest → size (`Content-Length`) + `sha256` (the
/// `Docker-Content-Digest` header, when the registry advertises it) +
/// content-type (the manifest media type). `Ok(None)` on 404.
fn metadata(&self, id: &ArtifactCoord) -> Result<Option<ArtifactMeta>> {
let url = self.manifest_url(id);
let build = |auth: &UpstreamAuth| {
apply_auth(&self.base_url, &url, auth, agent().head(&url))
.header("Accept", MANIFEST_ACCEPT)
};
match self.call_negotiated(&url, build)? {
Some(resp) => Ok(Some(ArtifactMeta {
size: header(&resp, "Content-Length").and_then(|s| s.parse::<u64>().ok()),
sha256: header(&resp, "Docker-Content-Digest"),
content_type: header(&resp, "Content-Type"),
})),
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "federation")]
use super::super::testutil::{MockResponse, MockServer};
use super::*;
#[cfg(feature = "federation")]
use sha2::{Digest, Sha256};
fn img(repo: &str, reference: &str) -> ArtifactCoord {
ArtifactCoord::new(repo, reference)
}
/// The manifest + blob URLs use the OCI Distribution v2 layout — repository
/// (which may contain `/`) and reference/digest in the right slots. RED-when-broken.
#[test]
fn manifest_and_blob_urls_use_v2_distribution_layout() {
let up = OciDistributionRegistry::ghcr(UpstreamAuth::None);
assert_eq!(
up.manifest_url(&img("nordisk/holger", "1.2")),
"https://ghcr.io/v2/nordisk/holger/manifests/1.2"
);
assert_eq!(
up.blob_url("nordisk/holger", "sha256:abc"),
"https://ghcr.io/v2/nordisk/holger/blobs/sha256:abc"
);
// A digest reference is a valid manifest coordinate too.
assert_eq!(
up.manifest_url(&img("library/redis", "sha256:deadbeef")),
"https://ghcr.io/v2/library/redis/manifests/sha256:deadbeef"
);
}
/// fetch() GETs `/v2/<repo>/manifests/<ref>` with the OCI `Accept` header and the
/// Bearer token, and returns the manifest bytes. RED-when-broken on path/header.
#[test]
#[cfg(feature = "federation")]
fn fetch_manifest_sends_accept_and_bearer_and_returns_bytes() {
let manifest =
br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}"#;
let server = MockServer::start(move |_m, _p| (200, manifest.to_vec()));
let up = OciDistributionRegistry::new(
"ghcr",
&server.base_url,
UpstreamAuth::Bearer {
token: "ghs_tok".into(),
},
);
let bytes = up
.fetch(&img("nordisk/holger", "1.2"))
.unwrap()
.expect("a manifest");
assert_eq!(bytes, manifest);
let req = &server.requests()[0];
assert_eq!(req.method, "GET");
assert_eq!(req.path, "/v2/nordisk/holger/manifests/1.2");
assert_eq!(req.header("authorization"), Some("Bearer ghs_tok"));
assert_eq!(
req.header("accept"),
Some(MANIFEST_ACCEPT),
"OCI manifest Accept header must be sent"
);
crate::functional_status(
"skidbladnir/federation-oci",
"oci_fetch_manifest_v2_path_accept_and_auth",
req.path == "/v2/nordisk/holger/manifests/1.2"
&& req.header("accept") == Some(MANIFEST_ACCEPT)
&& req.header("authorization") == Some("Bearer ghs_tok"),
"OCI Distribution manifest GET: /v2 path + Accept + Bearer",
);
}
/// A clean upstream 404 (unknown tag) is `Ok(None)`, never an error or empty bytes.
#[test]
#[cfg(feature = "federation")]
fn fetch_manifest_404_is_clean_none() {
let server = MockServer::start(|_m, _p| (404, b"unknown".to_vec()));
let up = OciDistributionRegistry::new("r", &server.base_url, UpstreamAuth::None);
assert!(up.fetch(&img("nordisk/ghost", "9.9")).unwrap().is_none());
// Exactly one upstream request was made (the manifest GET), no retry storm.
assert_eq!(server.request_count(), 1);
}
/// fetch_blob VERIFIES the blob content-addresses to its digest: the correct
/// digest returns the bytes; a WRONG digest is a hard error (content-addressing /
/// anti-poisoning). This is RED-when-broken via `verify_sha256` — the strongest
/// OCI-native integrity property.
#[test]
#[cfg(feature = "federation")]
fn fetch_blob_verifies_digest_and_rejects_tamper() {
let blob = b"layer-tar-bytes".to_vec();
let good_digest = format!("sha256:{}", hex::encode(Sha256::digest(&blob)));
let body = blob.clone();
let server = MockServer::start(move |_m, _p| (200, body.clone()));
let up = OciDistributionRegistry::new("r", &server.base_url, UpstreamAuth::None);
// Correct digest → bytes returned, path is the /v2/.../blobs/<digest> layout.
let got = up
.fetch_blob("nordisk/holger", &good_digest)
.unwrap()
.expect("a blob");
assert_eq!(got, blob);
assert_eq!(
server.requests()[0].path,
format!("/v2/nordisk/holger/blobs/{good_digest}")
);
// Wrong digest → the blob does NOT content-address to it → hard error.
let bad_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
let err = up.fetch_blob("nordisk/holger", bad_digest).unwrap_err();
assert!(
err.to_string().contains("sha256 mismatch"),
"tamper must be rejected: {err}"
);
}
/// metadata() HEADs the manifest and reports the size from Content-Length.
#[test]
#[cfg(feature = "federation")]
fn metadata_head_returns_size_and_uses_head() {
let server = MockServer::start(|_m, _p| (200, b"manifest-json".to_vec()));
let up = OciDistributionRegistry::new("r", &server.base_url, UpstreamAuth::None);
let meta = up
.metadata(&img("nordisk/holger", "1.2"))
.unwrap()
.expect("some meta");
assert_eq!(meta.size, Some(b"manifest-json".len() as u64));
assert_eq!(server.requests()[0].method, "HEAD");
assert_eq!(
server.requests()[0].path,
"/v2/nordisk/holger/manifests/1.2"
);
}
/// list() is an honest unsupported error (no cross-registry catalog walk here),
/// consistent with the other harbours — never a silent empty vec.
#[test]
#[cfg(feature = "federation")]
fn list_is_honest_unsupported() {
let up = OciDistributionRegistry::ghcr(UpstreamAuth::None);
assert!(up.list().is_err());
}
/// The `WWW-Authenticate: Bearer` challenge parser extracts realm/service/scope
/// (quoted, any order, case-insensitive scheme).
#[test]
fn parse_bearer_challenge_extracts_realm_service_scope() {
let ch = parse_bearer_challenge(
r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:nordisk/holger:pull""#,
)
.unwrap();
assert_eq!(ch.realm, "https://ghcr.io/token");
assert_eq!(ch.service.as_deref(), Some("ghcr.io"));
assert_eq!(ch.scope.as_deref(), Some("repository:nordisk/holger:pull"));
}
/// A malformed challenge is a HARD error, never a silent degrade to anonymous:
/// a non-Bearer scheme and a Bearer challenge missing `realm` both `Err`.
/// RED-when-broken (the handshake must refuse to proceed without a realm).
#[test]
fn parse_bearer_challenge_rejects_non_bearer_and_missing_realm() {
assert!(
parse_bearer_challenge(r#"Basic realm="x""#).is_err(),
"non-Bearer must error"
);
let err = parse_bearer_challenge(r#"Bearer service="ghcr.io",scope="repository:x:pull""#)
.unwrap_err();
assert!(
err.to_string().contains("missing realm"),
"must name the missing realm: {err}"
);
}
/// The FULL token auto-handshake against a header-emitting fixture: the first
/// manifest GET returns `401` + a `WWW-Authenticate: Bearer` challenge pointing at
/// the server's own `/token`; the harbour parses it, GETs the token endpoint (with
/// the echoed service/scope), then RETRIES the manifest with `Authorization: Bearer
/// <minted>` and succeeds. RED-when-broken: without the handshake the 401 surfaces
/// as an error and no manifest is returned.
#[test]
#[cfg(feature = "federation")]
fn token_handshake_parses_challenge_fetches_token_and_retries() {
use std::sync::{Arc, OnceLock};
let base = Arc::new(OnceLock::<String>::new());
let base_c = base.clone();
let server = MockServer::start_with(move |req| {
if req.path.starts_with("/token") {
return MockResponse::new(200, br#"{"token":"minted-tok"}"#.to_vec());
}
if req.header("authorization") == Some("Bearer minted-tok") {
MockResponse::new(200, br#"{"schemaVersion":2}"#.to_vec())
} else {
let b = base_c.get().cloned().unwrap_or_default();
MockResponse::new(401, Vec::new()).with_header(
"WWW-Authenticate",
format!(
r#"Bearer realm="{b}/token",service="reg",scope="repository:nordisk/holger:pull""#
),
)
}
});
base.set(server.base_url.clone()).unwrap();
let up = OciDistributionRegistry::new("ghcr", &server.base_url, UpstreamAuth::None);
let bytes = up
.fetch(&img("nordisk/holger", "1.2"))
.unwrap()
.expect("manifest after handshake");
assert_eq!(bytes, br#"{"schemaVersion":2}"#);
let reqs = server.requests();
assert_eq!(
reqs.len(),
3,
"manifest(401) → token → manifest(200): {reqs:?}"
);
assert_eq!(reqs[0].path, "/v2/nordisk/holger/manifests/1.2");
assert!(
reqs[1].path.starts_with("/token"),
"token endpoint hit: {}",
reqs[1].path
);
assert!(
reqs[1].path.contains("service=reg"),
"service echoed: {}",
reqs[1].path
);
assert!(
reqs[1]
.path
.contains("scope=repository:nordisk/holger:pull"),
"scope echoed: {}",
reqs[1].path
);
assert_eq!(
reqs[2].header("authorization"),
Some("Bearer minted-tok"),
"retry carries the minted token"
);
crate::functional_status(
"skidbladnir/federation-oci",
"oci_token_auto_handshake",
reqs.len() == 3 && reqs[2].header("authorization") == Some("Bearer minted-tok"),
"OCI WWW-Authenticate → token endpoint → retry-with-Bearer auto-handshake",
);
}
/// A `401` with NO `WWW-Authenticate` challenge is a hard error — the client
/// cannot invent a token endpoint. RED-when-broken.
#[test]
#[cfg(feature = "federation")]
fn token_handshake_401_without_challenge_is_hard_error() {
let server = MockServer::start_with(|_req| MockResponse::new(401, b"denied".to_vec()));
let up = OciDistributionRegistry::new("r", &server.base_url, UpstreamAuth::None);
let err = up.fetch(&img("nordisk/holger", "1.2")).unwrap_err();
assert!(
err.to_string().contains("no WWW-Authenticate"),
"must be explicit about the missing challenge: {err}"
);
}
/// metadata() surfaces the registry's `Docker-Content-Digest` (as the artifact
/// sha256) and the manifest media type — now that the mock can emit response
/// headers. RED-when-broken on the header→field mapping.
#[test]
#[cfg(feature = "federation")]
fn metadata_head_returns_docker_content_digest_and_media_type() {
let server = MockServer::start_with(|_req| {
MockResponse::new(200, b"manifest-json".to_vec())
.with_header("Docker-Content-Digest", "sha256:abc123")
.with_header("Content-Type", "application/vnd.oci.image.manifest.v1+json")
});
let up = OciDistributionRegistry::new("r", &server.base_url, UpstreamAuth::None);
let meta = up
.metadata(&img("nordisk/holger", "1.2"))
.unwrap()
.expect("some meta");
assert_eq!(meta.sha256.as_deref(), Some("sha256:abc123"));
assert_eq!(
meta.content_type.as_deref(),
Some("application/vnd.oci.image.manifest.v1+json")
);
assert_eq!(meta.size, Some(b"manifest-json".len() as u64));
assert_eq!(server.requests()[0].method, "HEAD");
}
}