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

nordisk/holger

server/repository/znippy-docker/src/lib.rs

//! Read-only Docker/OCI repository backend: serves the Registry HTTP API V2
//! straight out of a znippy archive whose entries already sit in on-wire layout
//! (`v2/{name}/manifests/{ref}`, `v2/{name}/blobs/{digest}`). No index is
//! synthesized here — unlike the deb/rpm siblings that build a native index from
//! bare packages, this backend is pure passthrough of pre-baked registry files.
//!
//! `DockerRepoZnippy` implements `RepositoryBackendTrait`; `handle_http2_request`
//! dispatches the four V2 verbs (version probe, `tags/list`, `manifests`,
//! `blobs`), and `fetch` maps an `ArtifactId` (namespace/name/version) onto the
//! manifest key. Writes are rejected — it's an immutable airgap artifact source.
//!
//! Gotcha: Docker image names are namespaced and span a variable number of path
//! segments (`library/nginx`), so routing anchors on the *trailing* verb token
//! rather than a fixed position — everything between `v2` and the verb is the
//! slash-joined name. Manifests are served with their own declared `mediaType`
//! (not a fixed OCI type) plus a `Docker-Content-Digest` computed over the exact
//! bytes served, so pull-by-digest verifies; `tags/list` is built with serde_json
//! because both the name and tags are attacker-controlled.

use std::path::PathBuf;
use std::sync::{Arc, OnceLock};

use anyhow::Result;

use traits::znippy_base::{not_found, ok};
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;

/// znippy-backed OCI / Docker registry implementing the Registry HTTP API V2.
///
///   * `GET /v2/`                               → 200 (version probe)
///   * `GET /v2/{name}/tags/list`               → tag list JSON
///   * `GET /v2/{name}/manifests/{reference}`   → image manifest
///   * `GET /v2/{name}/blobs/{digest}`          → layer / config blob
///
/// Content is stored in the archive under `v2/{name}/manifests/{ref}` and
/// `v2/{name}/blobs/{digest}` (the on-wire layout).
pub struct DockerRepoZnippy {
    base: ZnippyBase,
    /// The archive's file table, taken ONCE.
    ///
    /// `ZnippyArchive::list_files()` is `file_index.keys().cloned().collect()`
    /// — one String allocation per file in the archive, every call. The index
    /// endpoint below scans the whole table per request, so a 50 000-file
    /// archive allocated 50 000 Strings to answer one index request, and again
    /// for the next. This backend is READ-ONLY (`put` errors, `is_writable` is
    /// false) and the reader `Arc` is opened at boot and held for the process
    /// lifetime, so the table cannot change underneath: taking it once adds no
    /// staleness the held handle did not already have. Same shape deb, rpm,
    /// python and znippy-rust already use for their synthesized indexes.
    paths: OnceLock<Vec<String>>,
}

impl DockerRepoZnippy {
    pub fn new(name: String) -> Self {
        Self {
            base: ZnippyBase::new(name),
            paths: OnceLock::new(),
        }
    }

    pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
        Ok(Self {
            base: ZnippyBase::with_archive(name, &archive_path)?,
            paths: OnceLock::new(),
        })
    }

    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self {
            base: ZnippyBase::with_reader(name, reader),
            paths: OnceLock::new(),
        }
    }

    /// The archive's file table, filled on first use. See the `paths` field.
    fn paths(&self) -> &[String] {
        self.paths.get_or_init(|| match self.base.reader() {
            Some(r) => r.list_files().unwrap_or_default(),
            None => Vec::new(),
        })
    }

    pub fn list_files(&self) -> Vec<String> {
        self.paths().to_vec()
    }

    pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
        self.base.get_file(relative_path)
    }

    fn tags_list(&self, name: &str) -> Vec<u8> {
        // Built with serde_json (M10): `name` and the tags are attacker-
        // controlled (URL path + archive contents), so string interpolation
        // would allow JSON injection / a malformed body.
        let prefix = format!("v2/{}/manifests/", name);
        let mut tags: Vec<&str> = Vec::new();
        for f in self.paths() {
            if let Some(tag) = f.strip_prefix(&prefix) {
                if !tag.starts_with("sha256:") {
                    tags.push(tag);
                }
            }
        }
        tags.sort_unstable();
        tags.dedup();
        let doc = serde_json::json!({ "name": name, "tags": tags });
        serde_json::to_vec(&doc).unwrap_or_else(|_| b"{\"name\":\"\",\"tags\":[]}".to_vec())
    }
}

/// The manifest's declared `mediaType` (so an image index / schema2 manifest is
/// served with its real Content-Type), falling back to the OCI image manifest
/// type when the field is absent/unparsable.
fn manifest_media_type(data: &[u8]) -> String {
    serde_json::from_slice::<serde_json::Value>(data)
        .ok()
        .and_then(|v| {
            v.get("mediaType")
                .and_then(|m| m.as_str())
                .map(str::to_string)
        })
        .unwrap_or_else(|| "application/vnd.oci.image.manifest.v1+json".to_string())
}

/// `sha256:<hex>` over the exact bytes served — the value clients pulling by
/// digest verify against (the `Docker-Content-Digest` header).
fn sha256_digest(data: &[u8]) -> String {
    // REUSE-law: the ONE shared content-hash primitive (edda `nornir_hash`),
    // consumed instead of re-rolling `Sha256::new(); hex(...)`; the `sha256:` prefix
    // is the OCI digest form clients verify `Docker-Content-Digest` against.
    format!("sha256:{}", nornir_hash::sha256_hex(data))
}

impl RepositoryBackendTrait for DockerRepoZnippy {
    traits::znippy_read_only!(base);

    /// A blob download in pieces (lane HOL-RSS part 2): `v2/{name}/blobs/{digest}`, the
    /// archive file the whole-body door reads. A manifest is never one.
    fn stream_get(
        &self,
        suburl: &str,
        piece_bytes: usize,
    ) -> anyhow::Result<Option<traits::StreamedGet>> {
        let remainder = self.base.strip_repo(suburl);
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
        // `/v2/{name...}/blobs/{digest}`, as the whole-body door matches it
        if parts.first() != Some(&"v2") || parts.len() < 4 || parts[parts.len() - 2] != "blobs" {
            return Ok(None);
        }
        let name = parts[1..parts.len() - 2].join("/");
        let digest = parts[parts.len() - 1];
        self.base.stream_get(
            &format!("v2/{name}/blobs/{digest}"),
            "application/octet-stream",
            piece_bytes,
        )
    }

    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        if method == "HEAD" {
            // HOL-HEAD-1: the GET answer's status and headers, its length, no body.
            return self
                .handle_http2_request("GET", suburl, &[])
                .map(traits::backend::head_of);
        }
        let _ = body;
        log::debug!("docker repo znippy handle_http2_request.suburl={}", suburl);

        let remainder = self.base.strip_repo(suburl);
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();

        // Version probe.
        if parts.as_slice() == ["v2"] {
            return Ok((
                200,
                vec![(
                    "Docker-Distribution-Api-Version".into(),
                    "registry/2.0".into(),
                )],
                b"{}".to_vec(),
            ));
        }

        // Docker image names are namespaced (`library/nginx`, `org/team/img`),
        // so the name spans a variable number of path segments. Match on the
        // trailing verb instead of a fixed position: everything between `v2`
        // and the verb token is the slash-joined image name.
        if parts.first() == Some(&"v2") && parts.len() >= 3 {
            // /v2/{name...}/tags/list
            if parts.len() >= 4
                && parts[parts.len() - 2] == "tags"
                && parts[parts.len() - 1] == "list"
            {
                let name = parts[1..parts.len() - 2].join("/");
                return Ok(ok("application/json", self.tags_list(&name)));
            }
            let verb = parts[parts.len() - 2];
            let reference = parts[parts.len() - 1];
            let name = parts[1..parts.len() - 2].join("/");
            match verb {
                // /v2/{name...}/manifests/{ref}
                "manifests" => {
                    let archive_path = format!("v2/{}/manifests/{}", name, reference);
                    return match self.get_file(&archive_path) {
                        Ok(data) => {
                            // Echo the manifest's declared mediaType (an image
                            // index / schema2 manifest is mislabeled if served as
                            // a fixed OCI manifest type), and set the
                            // Docker-Content-Digest clients verify pull-by-digest
                            // against. Fall back to the OCI manifest type.
                            let content_type = manifest_media_type(&data);
                            let digest = sha256_digest(&data);
                            Ok((
                                200,
                                vec![
                                    ("Content-Type".into(), content_type),
                                    ("Docker-Content-Digest".into(), digest),
                                ],
                                data,
                            ))
                        }
                        Err(_) => Ok(not_found("Manifest unknown")),
                    };
                }
                // /v2/{name...}/blobs/{digest}
                "blobs" => {
                    let archive_path = format!("v2/{}/blobs/{}", name, reference);
                    return match self.get_file(&archive_path) {
                        Ok(data) => Ok(ok("application/octet-stream", data)),
                        Err(_) => Ok(not_found("Blob unknown")),
                    };
                }
                _ => {}
            }
        }

        Ok(not_found("Not found"))
    }

    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Docker
    }

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
        // name = image repository, version = tag/reference
        let name = match &id.namespace {
            Some(ns) => format!("{}/{}", ns, id.name),
            None => id.name.clone(),
        };
        let path = format!("v2/{}/manifests/{}", name, id.version);
        match self.get_file(&path) {
            Ok(data) => Ok(Some(traits::Bytes::from(data))),
            Err(_) => Ok(None),
        }
    }

    /// Image coordinates from the archive's `v2/{name}/manifests/{ref}` entries.
    ///
    /// Was absent, so `list()` fell through to the trait default and reported an
    /// empty Vec — which `/metrics` and the front page cannot tell apart from a
    /// repository that really holds nothing. Digest references (`sha256:…`) are
    /// skipped for the same reason `tags/list` skips them: they are a second
    /// name for a tag already counted, not a second image. `size_bytes` is the
    /// MANIFEST's size, not the image's — the blobs it points at are separate
    /// archive entries and summing them would need one extract per row.
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        let Some(reader) = self.base.reader() else {
            return Ok(Vec::new());
        };
        // ONE index read for the whole listing; every token after it is memory.
        let tokens = self.base.tokens().open();

        traits::list_archive_entries(
            reader.as_ref(),
            tokens.as_ref(),
            name_filter,
            limit,
            "application/vnd.oci.image.manifest.v1+json",
            |path| {
                let rest = path.strip_prefix("v2/")?;
                let (image, reference) = rest.rsplit_once("/manifests/")?;
                if image.is_empty() || reference.is_empty() || reference.starts_with("sha256:") {
                    return None;
                }
                // Image names span a variable number of segments
                // (`library/nginx`); `fetch` reads namespace+name back the same
                // way, so the split has to be its exact inverse.
                let (namespace, name) = match image.rsplit_once('/') {
                    Some((ns, n)) => (Some(ns.to_string()), n.to_string()),
                    None => (None, image.to_string()),
                };
                Some(ArtifactId {
                    namespace,
                    name,
                    version: reference.to_string(),
                })
            },
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use traits::test_support::{CountingReader, MemReader};

    #[cfg(feature = "testmatrix")]
    use traits::test_support::fstatus;

    #[test]
    fn test_new() {
        let repo = DockerRepoZnippy::new("docker-test".to_string());
        assert_eq!(repo.name(), "docker-test");
        assert!(!repo.is_writable());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-docker",
            "new_name_and_readonly",
            repo.name() == "docker-test" && !repo.is_writable(),
            &format!("name={} writable={}", repo.name(), repo.is_writable()),
        );
    }

    #[test]
    fn test_format() {
        let f = DockerRepoZnippy::new("x".into()).format();
        assert_eq!(f, ArtifactFormat::Docker);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-docker",
            "format_is_docker",
            f == ArtifactFormat::Docker,
            &format!("format = {f:?}"),
        );
    }

    #[test]
    fn serves_injected_archive() {
        // On-wire OCI layout: manifest under v2/{name}/manifests/{ref},
        // blob under v2/{name}/blobs/{digest}.
        let manifest_key = "v2/myimage/manifests/latest";
        let blob_digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
        let blob_key = "v2/myimage/blobs/sha256:1111111111111111111111111111111111111111111111111111111111111111";
        let manifest_body =
            br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}"#
                .to_vec();
        let blob_body = b"\x00\x01\x02layer-blob-bytes".to_vec();

        let reader = MemReader::new([
            (manifest_key, manifest_body.clone()),
            (blob_key, blob_body.clone()),
        ]);
        let repo = DockerRepoZnippy::with_reader("docker".to_string(), reader);

        // Small helper: find a header value (case-sensitive, matches the
        // exact casing the backend emits).
        let header = |hs: &[(String, String)], k: &str| -> Option<String> {
            hs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
        };

        // --- Manifest hit: GET /v2/myimage/manifests/latest ---
        let (mstatus, mhdrs, mbody) = repo
            .handle_http2_request("GET", "/v2/myimage/manifests/latest", b"")
            .unwrap();
        let manifest_ct = header(&mhdrs, "Content-Type");
        let manifest_ok = mstatus == 200
            && manifest_ct.as_deref() == Some("application/vnd.oci.image.manifest.v1+json")
            && mbody == manifest_body;
        assert!(
            manifest_ok,
            "manifest serve: status={} ct={:?}",
            mstatus, manifest_ct
        );

        // --- Blob hit: GET /v2/myimage/blobs/sha256:... ---
        let (bstatus, bhdrs, bbody) = repo
            .handle_http2_request("GET", &format!("/v2/myimage/blobs/{}", blob_digest), b"")
            .unwrap();
        let blob_ct = header(&bhdrs, "Content-Type");
        let blob_ok = bstatus == 200
            && blob_ct.as_deref() == Some("application/octet-stream")
            && bbody == blob_body;
        assert!(blob_ok, "blob serve: status={} ct={:?}", bstatus, blob_ct);

        // --- Miss: GET /v2/myimage/manifests/nope → 404 ---
        let (miss_status, _miss_hdrs, _miss_body) = repo
            .handle_http2_request("GET", "/v2/myimage/manifests/nope", b"")
            .unwrap();
        let miss_ok = miss_status == 404;
        assert!(miss_ok, "manifest miss: status={}", miss_status);

        // --- fetch() maps an ArtifactId to the manifest key ---
        let hit_id = ArtifactId {
            namespace: None,
            name: "myimage".into(),
            version: "latest".into(),
        };
        let fetched = repo.fetch(&hit_id).unwrap();
        let fetch_hit_ok = fetched.as_deref() == Some(manifest_body.as_slice());
        assert!(fetch_hit_ok, "fetch hit: {:?}", fetched.is_some());

        let miss_id = ArtifactId {
            namespace: None,
            name: "myimage".into(),
            version: "nope".into(),
        };
        let fetch_miss = repo.fetch(&miss_id).unwrap();
        let fetch_miss_ok = fetch_miss.is_none();
        assert!(fetch_miss_ok, "fetch miss should be None");

        #[cfg(feature = "testmatrix")]
        {
            fstatus(
                "znippy-docker",
                "serves_manifest_route",
                manifest_ok,
                &format!("GET {} -> {} ct={:?}", manifest_key, mstatus, manifest_ct),
            );
            fstatus(
                "znippy-docker",
                "serves_blob_route",
                blob_ok,
                &format!("GET {} -> {} ct={:?}", blob_key, bstatus, blob_ct),
            );
            fstatus(
                "znippy-docker",
                "manifest_miss_404",
                miss_ok,
                &format!("GET v2/myimage/manifests/nope -> {}", miss_status),
            );
            fstatus(
                "znippy-docker",
                "fetch_manifest_hit",
                fetch_hit_ok,
                &format!("fetch(myimage:latest) some={}", fetched.is_some()),
            );
            fstatus(
                "znippy-docker",
                "fetch_manifest_miss",
                fetch_miss_ok,
                &format!("fetch(myimage:nope) some={}", fetch_miss.is_some()),
            );
        }
    }

    #[test]
    fn serves_namespaced_image_and_tags_list() {
        // `docker pull nginx` resolves to the namespaced name `library/nginx`,
        // which spans two path segments. The route must treat everything
        // between `v2` and the trailing verb as the image name.
        let manifest_key = "v2/library/nginx/manifests/1.0";
        let blob_digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222";
        let blob_key = "v2/library/nginx/blobs/sha256:2222222222222222222222222222222222222222222222222222222222222222";
        let manifest_body = br#"{"schemaVersion":2}"#.to_vec();
        let blob_body = b"namespaced-layer".to_vec();

        let reader = MemReader::new([
            (manifest_key, manifest_body.clone()),
            (blob_key, blob_body.clone()),
        ]);
        let repo = DockerRepoZnippy::with_reader("docker".to_string(), reader);

        // --- Namespaced manifest: GET /v2/library/nginx/manifests/1.0 ---
        let (mstatus, _mhdrs, mbody) = repo
            .handle_http2_request("GET", "/v2/library/nginx/manifests/1.0", b"")
            .unwrap();
        let manifest_ok = mstatus == 200 && mbody == manifest_body;
        assert!(manifest_ok, "namespaced manifest: status={}", mstatus);

        // --- Namespaced blob ---
        let (bstatus, _bhdrs, bbody) = repo
            .handle_http2_request(
                "GET",
                &format!("/v2/library/nginx/blobs/{}", blob_digest),
                b"",
            )
            .unwrap();
        let blob_ok = bstatus == 200 && bbody == blob_body;
        assert!(blob_ok, "namespaced blob: status={}", bstatus);

        // --- Namespaced tags/list: GET /v2/library/nginx/tags/list ---
        let (tstatus, _thdrs, tbody) = repo
            .handle_http2_request("GET", "/v2/library/nginx/tags/list", b"")
            .unwrap();
        let tdoc: serde_json::Value = serde_json::from_slice(&tbody).unwrap_or_default();
        let tags_ok = tstatus == 200
            && tdoc.get("name").and_then(|v| v.as_str()) == Some("library/nginx")
            && tdoc.get("tags").and_then(|v| v.as_array()).map(|a| a.len()) == Some(1)
            && tdoc["tags"][0].as_str() == Some("1.0");
        assert!(
            tags_ok,
            "namespaced tags/list: status={} body={}",
            tstatus,
            String::from_utf8_lossy(&tbody)
        );

        // --- Single-segment tags/list still works (covers tags_list directly) ---
        let single = MemReader::new([("v2/redis/manifests/7", b"{}".to_vec())]);
        let repo_single = DockerRepoZnippy::with_reader("docker".to_string(), single);
        let (sstatus, _shdrs, sbody) = repo_single
            .handle_http2_request("GET", "/v2/redis/tags/list", b"")
            .unwrap();
        let sdoc: serde_json::Value = serde_json::from_slice(&sbody).unwrap_or_default();
        let single_ok = sstatus == 200
            && sdoc.get("name").and_then(|v| v.as_str()) == Some("redis")
            && sdoc["tags"][0].as_str() == Some("7");
        assert!(single_ok, "single-segment tags/list: status={}", sstatus);

        #[cfg(feature = "testmatrix")]
        {
            fstatus(
                "znippy-docker",
                "namespaced_manifest",
                manifest_ok,
                &format!("GET v2/library/nginx/manifests/1.0 -> {}", mstatus),
            );
            fstatus(
                "znippy-docker",
                "namespaced_blob",
                blob_ok,
                &format!("GET v2/library/nginx/blobs/.. -> {}", bstatus),
            );
            fstatus(
                "znippy-docker",
                "namespaced_tags_list",
                tags_ok,
                &format!("GET v2/library/nginx/tags/list -> {}", tstatus),
            );
            fstatus(
                "znippy-docker",
                "single_segment_tags_list",
                single_ok,
                &format!("GET v2/redis/tags/list -> {}", sstatus),
            );
        }
    }
    #[test]
    fn list_reports_tagged_images_not_a_false_zero() {
        let reader = MemReader::new([
            (
                "v2/library/nginx/manifests/1.25",
                br#"{"mediaType":"m"}"#.to_vec(),
            ),
            (
                "v2/library/nginx/manifests/latest",
                br#"{"mediaType":"m"}"#.to_vec(),
            ),
            // the same image under its digest: a second NAME, not a second image
            (
                "v2/library/nginx/manifests/sha256:abc",
                br#"{"mediaType":"m"}"#.to_vec(),
            ),
            ("v2/toolbox/manifests/v1", br#"{"a":1}"#.to_vec()),
            // blobs are layers, not artifacts
            ("v2/library/nginx/blobs/sha256:def", b"layer".to_vec()),
        ]);
        let repo = DockerRepoZnippy::with_reader("docker-test".to_string(), reader);

        let all = repo.list(None, 0).expect("list ok");
        assert_eq!(all.len(), 3, "two nginx tags + one single-segment image");

        for e in &all {
            assert!(
                repo.fetch(&e.id).expect("fetch ok").is_some(),
                "unfetchable: {:?}",
                e.id
            );
        }

        let nginx = repo.list(Some("nginx"), 0).expect("list ok");
        assert_eq!(nginx.len(), 2, "digest reference not counted a second time");
        assert_eq!(nginx[0].id.namespace.as_deref(), Some("library"));
        assert!(nginx[0].content_type.starts_with("application/vnd.oci"));
        assert!(nginx.iter().all(|e| e.size_bytes > 0), "real size, not 0");

        assert_eq!(
            repo.list(None, 1).expect("list ok").len(),
            1,
            "limit honoured"
        );
        assert!(DockerRepoZnippy::new("empty".into())
            .list(None, 0)
            .unwrap()
            .is_empty());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-docker",
            "list_reports_tagged_images",
            all.len() == 3 && nginx.len() == 2,
            &format!("all={} filtered={}", all.len(), nginx.len()),
        );
    }
    /// The archive file table is taken ONCE, not once per request.
    /// Measured before the `paths` OnceLock landed: ten `tags/list` requests =
    /// 10 full table reads. After: 1.
    #[test]
    fn the_archive_file_table_is_read_once_not_per_request() {
        let mem = MemReader::new([
            (
                "v2/library/nginx/manifests/1.25",
                br#"{"mediaType":"m"}"#.to_vec(),
            ),
            (
                "v2/library/nginx/manifests/latest",
                br#"{"mediaType":"m"}"#.to_vec(),
            ),
        ]);
        let counting = CountingReader::new(mem);
        let repo = DockerRepoZnippy::with_reader("docker-test".to_string(), counting.clone());

        assert_eq!(
            counting.table_reads(),
            0,
            "nothing is read before the first request"
        );
        for _ in 0..10 {
            let (status, _, body) = repo
                .handle_http2_request("GET", "/v2/library/nginx/tags/list", b"")
                .expect("tags request ok");
            assert_eq!(status, 200);
            assert!(String::from_utf8_lossy(&body).contains("latest"));
        }
        assert_eq!(
            counting.table_reads(),
            1,
            "ten tags/list requests must cost ONE table read, not ten"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-docker",
            "archive_table_read_once_not_per_request",
            counting.table_reads() == 1,
            &format!(
                "10 requests -> {} table reads (was 10)",
                counting.table_reads()
            ),
        );
    }
    /// The Archive tab was told there was nothing to browse on a repository
    /// that was serving images: the archive verbs
    /// were left at the trait default. The same false zero
    /// as `list()`, on three more verbs.
    #[test]
    fn the_archive_verbs_report_the_real_archive() {
        let reader = MemReader::new([
            ("v2/library/nginx/manifests/1.25", b"{}".to_vec()),
            ("v2/library/nginx/blobs/sha256:def", b"layer".to_vec()),
            ("v2/toolbox/manifests/v1", b"{}".to_vec()),
        ]);
        let repo = DockerRepoZnippy::with_reader("docker-test".to_string(), reader);

        assert!(repo.caps().archive, "a loaded reader IS an archive");
        assert_eq!(repo.archive().unwrap().paths(None).len(), 3);
        assert_eq!(
            repo.archive().unwrap().paths(Some("v2/library/")).len(),
            2,
            "prefix narrows"
        );

        let info = repo.archive().unwrap().info();
        assert_eq!(info.file_count, 3);
        assert_eq!(
            info.total_uncompressed_bytes,
            2 + 5 + 2,
            "real bytes, not zero"
        );
        assert_eq!(info.archive_path, "docker-test");

        let empty = DockerRepoZnippy::new("empty".into());
        assert!(!empty.caps().archive);
        assert!(traits::is_unsupported(&empty.archive().unwrap_err()));
    }

    /// Caps and methods agree, with and without an archive loaded.
    #[test]
    fn caps_match_methods() {
        struct One;
        impl znippy_common::ZnippyReader for One {
            fn list_files(&self) -> anyhow::Result<Vec<String>> {
                Ok(vec!["x".into()])
            }
            fn extract_file(&self, _: &str) -> anyhow::Result<Vec<u8>> {
                Ok(Vec::new())
            }
            fn contains(&self, p: &str) -> bool {
                p == "x"
            }
            fn file_size(&self, _: &str) -> Option<u64> {
                Some(0)
            }
        }
        traits::backend::conformance::check(&DockerRepoZnippy::new("r".into())).unwrap();
        traits::backend::conformance::check(&DockerRepoZnippy::with_reader(
            "r".into(),
            std::sync::Arc::new(One),
        ))
        .unwrap();
    }
}