Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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");
    }
}