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

nordisk/holger

traits/src/backend.rs

//! **The repository backend seam** — the owner's original six methods, plus ONE
//! answer to "what else can you do", plus the methods that answer unlocks.
//!
//! ```text
//! name · format · is_writable · fetch · put · handle_http2_request   the core
//! caps                                                               ONE answer
//! list · archive · group_members · fetch_chunks · put_from_reader ·
//! handle_http2_upload · coordinate_for_path · delete_artifact         unlocked by caps
//! ```
//!
//! Every unlocked method defaults to [`Unsupported`], never to an empty answer:
//! an empty listing, an empty archive or a `None` coordinate is a real answer a
//! caller believes, and a default that hands one out hides the backend that
//! forgot to implement it. [`conformance::check`] holds the two halves against
//! each other — a cap without its method, or a method without its cap, fails it.
//!
//! A backend that wraps ONE other backend implements [`Decorator`] instead of
//! this trait, and the blanket impl forwards every method through
//! [`Decorator::around`]; a method added here cannot be dropped by a wrapper.
//! The one exception is [`RepositoryBackendTrait::stream_get`] (a `GET` answered from
//! pieces, lane HOL-RSS): it is an answer the whole-body door also gives, so a wrapper
//! that changes reads must not hand it on unseen; it forwards only by choice.

use crate::{ArtifactEntry, ArtifactFormat, ArtifactId, Bytes};
use serde::{Deserialize, Serialize};

/// `(status, headers, body)` — the HTTP-door reply every backend answers with.
pub type HttpReply = (u16, Vec<(String, String)>, Vec<u8>);

/// [`HttpReply`] with a refcounted body (lane HOL-PERF-2): an index rendered once per
/// change is handed out as the same `Bytes` to every request, never copied.
pub type HttpBytesReply = (u16, Vec<(String, String)>, Bytes);

/// One thing a backend can do beyond the core.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Cap {
    /// [`RepositoryBackendTrait::list`] is a real enumeration.
    Listing,
    /// [`RepositoryBackendTrait::archive`] — the backend is backed by a znippy archive.
    Archive,
    /// [`RepositoryBackendTrait::group_members`] — a read-only view over other
    /// repositories that owns no storage; its archive is its members' SUM.
    Group,
    /// [`RepositoryBackendTrait::fetch_chunks`] reads in pieces.
    StreamingReads,
    /// [`RepositoryBackendTrait::put_from_reader`] and
    /// [`RepositoryBackendTrait::handle_http2_upload`] write as the body arrives.
    StreamingUploads,
    /// [`RepositoryBackendTrait::coordinate_for_path`] covers the backend's whole
    /// path scheme, so `Ok(None)` reliably means "this path names no artifact".
    PathMapping,
    /// [`RepositoryBackendTrait::delete_artifact`] reclaims one artifact.
    Delete,
}

impl Cap {
    /// Every cap, so a check can enumerate them.
    pub const ALL: [Cap; 7] = [
        Cap::Listing,
        Cap::Archive,
        Cap::Group,
        Cap::StreamingReads,
        Cap::StreamingUploads,
        Cap::PathMapping,
        Cap::Delete,
    ];
}

/// What a backend can do beyond the core — the ONE answer that replaced seven
/// boolean flags. No default: a backend states it, so a forgotten flag is a
/// compile error and a wrong one is a [`conformance::check`] failure.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Caps {
    pub listing: bool,
    pub archive: bool,
    pub group: bool,
    pub streaming_reads: bool,
    pub streaming_uploads: bool,
    pub path_mapping: bool,
    pub delete: bool,
}

impl Caps {
    /// The core and nothing else.
    pub const NONE: Caps = Caps {
        listing: false,
        archive: false,
        group: false,
        streaming_reads: false,
        streaming_uploads: false,
        path_mapping: false,
        delete: false,
    };

    /// Every cap on.
    pub const ALL: Caps = Caps {
        listing: true,
        archive: true,
        group: true,
        streaming_reads: true,
        streaming_uploads: true,
        path_mapping: true,
        delete: true,
    };

    /// A real listing and nothing else.
    pub const LISTING: Caps = Caps {
        listing: true,
        ..Caps::NONE
    };

    /// A znippy backend: a listing, plus the archive when one is loaded.
    pub const fn znippy(has_archive: bool) -> Caps {
        Caps {
            listing: true,
            archive: has_archive,
            ..Caps::NONE
        }
    }

    pub fn has(&self, cap: Cap) -> bool {
        match cap {
            Cap::Listing => self.listing,
            Cap::Archive => self.archive,
            Cap::Group => self.group,
            Cap::StreamingReads => self.streaming_reads,
            Cap::StreamingUploads => self.streaming_uploads,
            Cap::PathMapping => self.path_mapping,
            Cap::Delete => self.delete,
        }
    }

    /// These caps with `cap` switched to `on`.
    #[must_use]
    pub fn with(mut self, cap: Cap, on: bool) -> Caps {
        *match cap {
            Cap::Listing => &mut self.listing,
            Cap::Archive => &mut self.archive,
            Cap::Group => &mut self.group,
            Cap::StreamingReads => &mut self.streaming_reads,
            Cap::StreamingUploads => &mut self.streaming_uploads,
            Cap::PathMapping => &mut self.path_mapping,
            Cap::Delete => &mut self.delete,
        } = on;
        self
    }
}

/// The answer of an unlocked method on a backend whose [`Caps`] do not unlock
/// it. A typed error, so a caller (and [`conformance::check`]) can tell "this
/// backend cannot" from "this backend tried and failed".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unsupported {
    pub backend: String,
    pub cap: Cap,
}

impl std::fmt::Display for Unsupported {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "backend '{}' does not support {:?}",
            self.backend, self.cap
        )
    }
}

impl std::error::Error for Unsupported {}

/// `Err(Unsupported)` for `backend` and `cap` — the body of every unlocked
/// method's default.
/// **A `HEAD` answer from the `GET` answer** (HOL-HEAD-1): the same status and
/// headers, no body, and a `Content-Length` naming the `GET` body's length unless
/// the reply names one already. A backend that has no cheaper way to know a size
/// answers `HEAD` as `handle_http2_request("GET", ..).map(head_of)`; one that has
/// (a file's metadata) answers it itself.
pub fn head_of((status, mut headers, body): HttpReply) -> HttpReply {
    if !headers
        .iter()
        .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
    {
        headers.push(("Content-Length".to_string(), body.len().to_string()));
    }
    (status, headers, Vec::new())
}

/// [`head_of`] for an [`HttpBytesReply`].
pub fn head_of_bytes((status, mut headers, body): HttpBytesReply) -> HttpBytesReply {
    if !headers
        .iter()
        .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
    {
        headers.push(("Content-Length".to_string(), body.len().to_string()));
    }
    (status, headers, Bytes::new())
}

pub fn unsupported<T>(backend: &str, cap: Cap) -> anyhow::Result<T> {
    Err(Unsupported {
        backend: backend.to_string(),
        cap,
    }
    .into())
}

/// Whether `e` is an [`Unsupported`] (anywhere in its chain).
pub fn is_unsupported(e: &anyhow::Error) -> bool {
    e.chain().any(|c| c.is::<Unsupported>())
}

/// What a backend's archive holds: every raw path with its uncompressed size.
/// The ONE archive accessor's answer — the file listing and the stats are two
/// views of it, so they cannot disagree.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Archive {
    /// The repository/archive name — a human-readable handle, not a filesystem path.
    pub name: String,
    /// `(archive-raw path, uncompressed bytes)`, in the archive's order. A group's
    /// archive concatenates its members', so a path may repeat.
    pub files: Vec<(String, u64)>,
}

impl Archive {
    /// The archive behind `reader`, under `name`. Reads the index only; nothing
    /// is extracted.
    pub fn read(name: &str, reader: &dyn znippy_common::ZnippyReader) -> anyhow::Result<Archive> {
        let files = reader
            .list_files()?
            .into_iter()
            .map(|f| {
                let size = reader.file_size(&f).unwrap_or(0);
                (f, size)
            })
            .collect();
        Ok(Archive {
            name: name.to_string(),
            files,
        })
    }

    /// File count and total uncompressed bytes under the archive's name.
    pub fn info(&self) -> ArchiveInfo {
        ArchiveInfo {
            file_count: self.files.len() as u64,
            total_uncompressed_bytes: self.files.iter().map(|(_, s)| *s).sum(),
            archive_path: self.name.clone(),
        }
    }

    /// The raw paths, optionally filtered to those starting with `prefix`,
    /// each path once.
    pub fn paths(self, prefix: Option<&str>) -> Vec<String> {
        let mut seen = std::collections::HashSet::new();
        self.files
            .into_iter()
            .map(|(f, _)| f)
            .filter(|f| prefix.is_none_or(|p| f.starts_with(p)))
            .filter(|f| seen.insert(f.clone()))
            .collect()
    }
}

/// Stats for the znippy archive backing a repository (mirror of the proto
/// `ArchiveInfoResponse`). `archive_path` is the repository/archive name.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArchiveInfo {
    pub file_count: u64,
    pub total_uncompressed_bytes: u64,
    pub archive_path: String,
}

/// An artifact delivered in pieces — what [`RepositoryBackendTrait::fetch_chunks`]
/// returns. The pieces concatenated are the artifact. `len` is the total when
/// the backend knows it before the first piece (a door charges its read budget
/// up front and sends it as `Content-Length`); `None` = charged as pieces go.
pub struct ChunkIter {
    pub len: Option<u64>,
    pub pieces: Box<dyn Iterator<Item = anyhow::Result<Bytes>> + Send>,
}

impl Iterator for ChunkIter {
    type Item = anyhow::Result<Bytes>;
    fn next(&mut self) -> Option<Self::Item> {
        self.pieces.next()
    }
}

/// **A `GET` answered from pieces** (lane HOL-RSS): what
/// [`RepositoryBackendTrait::stream_get`] hands the door: the `200`'s headers and its
/// body as pieces.
pub struct StreamedGet {
    pub headers: Vec<(String, String)>,
    pub body: ChunkIter,
}

/// **A file in pieces of at most `chunk_bytes`**, its length from its metadata: the
/// streamed body of a backend whose artifact is one file (raw, file-oci's blobs, the
/// intake chain's sealed blobs). The file is read as the pieces are pulled; what the
/// reader holds is one piece.
pub fn file_chunks(file: std::fs::File, chunk_bytes: usize) -> ChunkIter {
    let len = file.metadata().ok().map(|m| m.len());
    ChunkIter {
        len,
        pieces: Box::new(FileChunks {
            file,
            chunk: chunk_bytes.max(1),
            left: len,
            done: false,
        }),
    }
}

struct FileChunks {
    file: std::fs::File,
    chunk: usize,
    /// What the file's metadata says is still to read: a piece buffer is never larger
    /// (a 16 KiB file allocated, and zeroed, a whole 1 MiB piece per download).
    left: Option<u64>,
    done: bool,
}

impl Iterator for FileChunks {
    type Item = anyhow::Result<Bytes>;

    fn next(&mut self) -> Option<Self::Item> {
        use std::io::Read as _;
        if self.done {
            return None;
        }
        // At least one byte, so a file that grew past its metadata still reads to its end.
        let size = match self.left {
            Some(left) => (left.min(self.chunk as u64) as usize).max(1),
            None => self.chunk,
        };
        let mut buf = vec![0u8; size];
        let mut filled = 0usize;
        while filled < buf.len() {
            match self.file.read(&mut buf[filled..]) {
                Ok(0) => break,
                Ok(n) => filled += n,
                Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                Err(e) => {
                    self.done = true;
                    return Some(Err(e.into()));
                }
            }
        }
        if filled == 0 {
            self.done = true;
            return None;
        }
        if filled < buf.len() {
            self.done = true;
            buf.truncate(filled);
        }
        if let Some(left) = self.left.as_mut() {
            *left = left.saturating_sub(filled as u64);
        }
        Some(Ok(Bytes::from(buf)))
    }
}

/// Slice one `Bytes` into pieces of at most `chunk_bytes` — refcount bumps, no copies.
pub fn chunks_of(whole: Bytes, chunk_bytes: usize) -> ChunkIter {
    let step = chunk_bytes.max(1);
    let len = whole.len();
    ChunkIter {
        len: Some(len as u64),
        pieces: Box::new((0..len).step_by(step).map(move |start| {
            let end = (start + step).min(len);
            Ok(whole.slice(start..end))
        })),
    }
}

/// The most a collected upload preallocates from a client's declared length: a
/// lying `Content-Length` must not make a backend reserve more than this.
pub const READER_PREALLOC_CAP: u64 = 64 * 1024 * 1024;

/// Collect `body` into a buffer, preallocating at most [`READER_PREALLOC_CAP`].
pub fn collect_body(
    body: &mut dyn std::io::Read,
    size_hint: Option<u64>,
) -> anyhow::Result<Vec<u8>> {
    let mut buf = Vec::with_capacity(size_hint.unwrap_or(0).min(READER_PREALLOC_CAP) as usize);
    body.read_to_end(&mut buf)?;
    Ok(buf)
}

/// **The streaming contract is the door's contract** (stinky 14). The hot routes
/// move bytes through [`read_chunks`](dyn RepositoryBackendTrait::read_chunks)
/// and [`write_from_reader`](dyn RepositoryBackendTrait::write_from_reader):
/// the HTTP door's streamed GET and streamed PUT, gRPC `StreamArtifact` and
/// `PutArtifactStream`. A backend that declares [`Caps::streaming_reads`] /
/// [`Caps::streaming_uploads`] is then streamed end to end, and one that does
/// not is served by the whole-buffer `fetch`/`put` below, which are the
/// CONVENIENCE every backend implements (and what the unary `FetchArtifact` /
/// `PutArtifact`, the gates that must read a body whole, and the in-process
/// callers use).
pub trait RepositoryBackendTrait: Send + Sync {
    // ── the core ─────────────────────────────────────────────────────────
    fn name(&self) -> &str;
    fn format(&self) -> ArtifactFormat;
    fn is_writable(&self) -> bool;
    /// The whole artifact — the convenience; a door asks `read_chunks`.
    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Bytes>>;
    /// Store a whole body — the convenience; a door asks `write_from_reader`.
    fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()>;
    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpReply>;

    /// [`handle_http2_request`](Self::handle_http2_request) with a refcounted body (lane
    /// HOL-PERF-2): what the whole-body door calls. A backend that renders an index once
    /// per change answers it as the same `Bytes` every time; the default wraps the `Vec`.
    fn handle_http2_request_bytes(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpBytesReply> {
        self.handle_http2_request(method, suburl, body)
            .map(|(s, h, b)| (s, h, Bytes::from(b)))
    }

    /// What this backend can do beyond the core. May reflect runtime state (a
    /// znippy backend has [`Caps::archive`] exactly while a reader is loaded).
    fn caps(&self) -> Caps;

    // ── unlocked by caps; each defaults to `Unsupported` ─────────────────

    /// [`Cap::Listing`]: the artifacts held, optionally filtered by a substring
    /// of the name, capped at `limit` (`0` = no cap).
    fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
        let _ = (name_filter, limit);
        unsupported(self.name(), Cap::Listing)
    }

    /// [`Cap::Archive`]: the backing archive's raw paths and sizes.
    fn archive(&self) -> anyhow::Result<Archive> {
        unsupported(self.name(), Cap::Archive)
    }

    /// [`Cap::Group`]: the member repository names, in resolution order.
    fn group_members(&self) -> anyhow::Result<Vec<String>> {
        unsupported(self.name(), Cap::Group)
    }

    /// [`Cap::StreamingReads`]: the artifact in pieces of at most `chunk_bytes`,
    /// the first piece leaving before the last is read. A caller that wants
    /// pieces from ANY backend asks [`read_chunks`](dyn RepositoryBackendTrait::read_chunks).
    fn fetch_chunks(
        &self,
        id: &ArtifactId,
        chunk_bytes: usize,
    ) -> anyhow::Result<Option<ChunkIter>> {
        let _ = (id, chunk_bytes);
        unsupported(self.name(), Cap::StreamingReads)
    }

    /// **A `GET` of `suburl` answered from pieces** (lane HOL-RSS, 2026-11-01): the
    /// headers and the body (pieces of at most `piece_bytes`) of the `200` this backend's
    /// [`handle_http2_request_bytes`](Self::handle_http2_request_bytes) answers, when it
    /// can read that body piece by piece, so the door holds a piece and never the
    /// artifact. `Ok(None)` (the default): the whole-body door answers, which is always
    /// right. A backend answers here only for a path whose `200` body is exactly those
    /// bytes; it is asked after every gate the whole-body door is behind.
    fn stream_get(&self, suburl: &str, piece_bytes: usize) -> anyhow::Result<Option<StreamedGet>> {
        let _ = (suburl, piece_bytes);
        Ok(None)
    }

    /// [`Cap::StreamingUploads`]: store from a reader as the body arrives.
    /// `size_hint` is the client's declared length — a hint, never a promise.
    /// A read error from `body` is the door REFUSING the upload; the backend
    /// must leave nothing behind. A caller that has a reader for ANY backend
    /// asks [`write_from_reader`](dyn RepositoryBackendTrait::write_from_reader).
    fn put_from_reader(
        &self,
        id: &ArtifactId,
        body: &mut dyn std::io::Read,
        size_hint: Option<u64>,
    ) -> anyhow::Result<()> {
        let _ = (id, body, size_hint);
        unsupported(self.name(), Cap::StreamingUploads)
    }

    /// [`Cap::StreamingUploads`]: the streaming twin of `handle_http2_request`
    /// for a WRITE verb. `Ok(None)` = "not handled here"; the door then
    /// collects the body and calls `handle_http2_request`.
    fn handle_http2_upload(
        &self,
        method: &str,
        suburl: &str,
        body: &mut dyn std::io::Read,
        size_hint: Option<u64>,
    ) -> anyhow::Result<Option<HttpReply>> {
        let _ = (method, suburl, body, size_hint);
        unsupported(self.name(), Cap::StreamingUploads)
    }

    /// [`Cap::PathMapping`]: the coordinate a raw HTTP `suburl` (with its
    /// leading `/<repo>/…`) addresses. `Ok(None)` = the path names no artifact
    /// (an index). Read by the serve-time quarantine gate.
    fn coordinate_for_path(&self, suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
        let _ = suburl;
        unsupported(self.name(), Cap::PathMapping)
    }

    /// [`Cap::Delete`]: reclaim one artifact. `expected_content_id` is the
    /// lowercase-hex digest the caller computed; the backend MUST recompute it
    /// over what is stored and refuse on a mismatch. A missing artifact is an
    /// error, never a silent success.
    fn delete_artifact(&self, id: &ArtifactId, expected_content_id: &str) -> anyhow::Result<()> {
        let _ = (id, expected_content_id);
        unsupported(self.name(), Cap::Delete)
    }
}

impl dyn RepositoryBackendTrait + '_ {
    /// The artifact in pieces from ANY backend: streamed where it has
    /// [`Cap::StreamingReads`], sliced from `fetch` (refcount bumps) where not.
    pub fn read_chunks(
        &self,
        id: &ArtifactId,
        chunk_bytes: usize,
    ) -> anyhow::Result<Option<ChunkIter>> {
        if self.caps().streaming_reads {
            return self.fetch_chunks(id, chunk_bytes);
        }
        Ok(self.fetch(id)?.map(|whole| chunks_of(whole, chunk_bytes)))
    }

    /// Store from a reader on ANY backend: streamed where it has
    /// [`Cap::StreamingUploads`], collected and `put` where not.
    pub fn write_from_reader(
        &self,
        id: &ArtifactId,
        body: &mut dyn std::io::Read,
        size_hint: Option<u64>,
    ) -> anyhow::Result<()> {
        if self.caps().streaming_uploads {
            return self.put_from_reader(id, body, size_hint);
        }
        let buf = collect_body(body, size_hint)?;
        self.put(id, &buf)
    }

    /// The coordinate for `suburl` where the backend maps paths; `None` where it
    /// does not (a caller that must tell the two apart asks [`Caps::path_mapping`]).
    pub fn coordinate(&self, suburl: &str) -> Option<ArtifactId> {
        if !self.caps().path_mapping {
            return None;
        }
        self.coordinate_for_path(suburl).ok().flatten()
    }

    /// A group's members; empty for a backend that owns its storage.
    pub fn members(&self) -> Vec<String> {
        if !self.caps().group {
            return Vec::new();
        }
        self.group_members().unwrap_or_default()
    }
}

// ─────────────────────────── decorators ───────────────────────────

/// Which kind of call a [`Decorator`] is asked to let through.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
    Read,
    Write,
}

impl Access {
    /// `GET`/`HEAD` read; every other verb writes.
    pub fn of_method(method: &str) -> Access {
        if matches!(method, "GET" | "HEAD") {
            Access::Read
        } else {
            Access::Write
        }
    }
}

/// **A backend that wraps ONE other backend** (a quota guard, a hold, a
/// pull-through proxy). Implement this instead of [`RepositoryBackendTrait`]:
/// the blanket impl below forwards EVERY trait method through
/// [`around`](Self::around), so a method added to the trait reaches the inner
/// backend without the wrapper being touched. A wrapper overrides only the
/// methods it changes.
///
/// The four identity answers are required because a wrapper may have to give
/// them while its inner backend is away (a released hold).
pub trait Decorator: Send + Sync {
    fn name(&self) -> &str;
    fn format(&self) -> ArtifactFormat;
    fn is_writable(&self) -> bool;
    fn caps(&self) -> Caps;

    /// Run `call` against the wrapped backend, for one call of kind `access`.
    fn around<T>(
        &self,
        access: Access,
        call: impl FnOnce(&dyn RepositoryBackendTrait) -> anyhow::Result<T>,
    ) -> anyhow::Result<T>;

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Bytes>> {
        self.around(Access::Read, |b| b.fetch(id))
    }
    fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
        self.around(Access::Write, |b| b.put(id, data))
    }
    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpReply> {
        self.around(Access::of_method(method), |b| {
            b.handle_http2_request(method, suburl, body)
        })
    }
    fn handle_http2_request_bytes(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpBytesReply> {
        self.around(Access::of_method(method), |b| {
            b.handle_http2_request_bytes(method, suburl, body)
        })
    }
    fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
        self.around(Access::Read, |b| b.list(name_filter, limit))
    }
    fn archive(&self) -> anyhow::Result<Archive> {
        self.around(Access::Read, |b| b.archive())
    }
    fn group_members(&self) -> anyhow::Result<Vec<String>> {
        self.around(Access::Read, |b| b.group_members())
    }
    fn fetch_chunks(
        &self,
        id: &ArtifactId,
        chunk_bytes: usize,
    ) -> anyhow::Result<Option<ChunkIter>> {
        self.around(Access::Read, |b| b.fetch_chunks(id, chunk_bytes))
    }
    fn put_from_reader(
        &self,
        id: &ArtifactId,
        body: &mut dyn std::io::Read,
        size_hint: Option<u64>,
    ) -> anyhow::Result<()> {
        self.around(Access::Write, |b| b.put_from_reader(id, body, size_hint))
    }
    fn handle_http2_upload(
        &self,
        method: &str,
        suburl: &str,
        body: &mut dyn std::io::Read,
        size_hint: Option<u64>,
    ) -> anyhow::Result<Option<HttpReply>> {
        self.around(Access::Write, |b| {
            b.handle_http2_upload(method, suburl, body, size_hint)
        })
    }
    fn coordinate_for_path(&self, suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
        self.around(Access::Read, |b| b.coordinate_for_path(suburl))
    }
    /// The one method that is NOT forwarded by default: a wrapper may change what a
    /// read answers (the pull-through walk, the intake chain's sealed store), and a
    /// read in pieces straight off the inner backend would skip that. A wrapper that
    /// leaves reads as they are forwards it itself, through `around`.
    fn stream_get(&self, suburl: &str, piece_bytes: usize) -> anyhow::Result<Option<StreamedGet>> {
        let _ = (suburl, piece_bytes);
        Ok(None)
    }
    fn delete_artifact(&self, id: &ArtifactId, expected_content_id: &str) -> anyhow::Result<()> {
        self.around(Access::Write, |b| {
            b.delete_artifact(id, expected_content_id)
        })
    }
}

impl<D: Decorator> RepositoryBackendTrait for D {
    fn name(&self) -> &str {
        Decorator::name(self)
    }
    fn format(&self) -> ArtifactFormat {
        Decorator::format(self)
    }
    fn is_writable(&self) -> bool {
        Decorator::is_writable(self)
    }
    fn caps(&self) -> Caps {
        Decorator::caps(self)
    }
    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Bytes>> {
        Decorator::fetch(self, id)
    }
    fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
        Decorator::put(self, id, data)
    }
    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpReply> {
        Decorator::handle_http2_request(self, method, suburl, body)
    }
    fn handle_http2_request_bytes(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<HttpBytesReply> {
        Decorator::handle_http2_request_bytes(self, method, suburl, body)
    }
    fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
        Decorator::list(self, name_filter, limit)
    }
    fn archive(&self) -> anyhow::Result<Archive> {
        Decorator::archive(self)
    }
    fn group_members(&self) -> anyhow::Result<Vec<String>> {
        Decorator::group_members(self)
    }
    fn fetch_chunks(
        &self,
        id: &ArtifactId,
        chunk_bytes: usize,
    ) -> anyhow::Result<Option<ChunkIter>> {
        Decorator::fetch_chunks(self, id, chunk_bytes)
    }
    fn put_from_reader(
        &self,
        id: &ArtifactId,
        body: &mut dyn std::io::Read,
        size_hint: Option<u64>,
    ) -> anyhow::Result<()> {
        Decorator::put_from_reader(self, id, body, size_hint)
    }
    fn handle_http2_upload(
        &self,
        method: &str,
        suburl: &str,
        body: &mut dyn std::io::Read,
        size_hint: Option<u64>,
    ) -> anyhow::Result<Option<HttpReply>> {
        Decorator::handle_http2_upload(self, method, suburl, body, size_hint)
    }
    fn coordinate_for_path(&self, suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
        Decorator::coordinate_for_path(self, suburl)
    }
    fn stream_get(&self, suburl: &str, piece_bytes: usize) -> anyhow::Result<Option<StreamedGet>> {
        Decorator::stream_get(self, suburl, piece_bytes)
    }
    fn delete_artifact(&self, id: &ArtifactId, expected_content_id: &str) -> anyhow::Result<()> {
        Decorator::delete_artifact(self, id, expected_content_id)
    }
}

// ─────────────────────────── conformance ───────────────────────────

/// **Caps and methods, held against each other.** Every backend crate's tests
/// call [`check`] on what it builds; a decorator's tests call it on the
/// decorator wrapped around [`Everything`] (which has every cap) and around a
/// core-only backend.
pub mod conformance {
    use super::*;

    /// A coordinate no store holds.
    pub fn probe_id() -> ArtifactId {
        ArtifactId {
            namespace: None,
            name: "holger-caps-probe".into(),
            version: "0".into(),
        }
    }

    /// A body that refuses to be read — the door's "this upload is refused", so
    /// a streaming probe can never store anything.
    struct Refused;
    impl std::io::Read for Refused {
        fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
            Err(std::io::Error::other("conformance probe: refused"))
        }
    }

    /// Which of the methods behind `cap` answered `Unsupported` on `b` (two
    /// methods for [`Cap::StreamingUploads`], one for every other cap). The
    /// probes cannot store or delete anything: a refused body, a coordinate no
    /// store holds and a digest nothing hashes to.
    pub fn unsupported_answers(b: &dyn RepositoryBackendTrait, cap: Cap) -> Vec<bool> {
        let id = probe_id();
        let suburl = format!("/{}/holger-caps-probe", b.name());
        let un = |r: anyhow::Result<()>| r.err().is_some_and(|e| is_unsupported(&e));
        match cap {
            Cap::Listing => vec![un(b.list(None, 1).map(drop))],
            Cap::Archive => vec![un(b.archive().map(drop))],
            Cap::Group => vec![un(b.group_members().map(drop))],
            Cap::StreamingReads => vec![un(b.fetch_chunks(&id, 1).map(drop))],
            Cap::StreamingUploads => vec![
                un(b.put_from_reader(&id, &mut Refused, Some(1))),
                un(b.handle_http2_upload("PUT", &suburl, &mut Refused, Some(1))
                    .map(drop)),
            ],
            Cap::PathMapping => vec![un(b.coordinate_for_path(&suburl).map(drop))],
            Cap::Delete => vec![un(b.delete_artifact(&id, &"0".repeat(64)))],
        }
    }

    /// `Ok` when every cap `b` claims has its methods and every method `b` has
    /// is claimed; otherwise the disagreements, named.
    pub fn check(b: &dyn RepositoryBackendTrait) -> Result<(), String> {
        let caps = b.caps();
        let bad: Vec<String> = Cap::ALL
            .into_iter()
            .filter_map(|cap| {
                let answers = unsupported_answers(b, cap);
                match (
                    caps.has(cap),
                    answers.iter().any(|u| *u),
                    answers.iter().all(|u| *u),
                ) {
                    (true, true, _) => Some(format!(
                        "{cap:?} is claimed but its method answers Unsupported"
                    )),
                    (false, _, false) => {
                        Some(format!("{cap:?} is not claimed but its method answers"))
                    }
                    _ => None,
                }
            })
            .collect();
        if bad.is_empty() {
            Ok(())
        } else {
            Err(format!("backend '{}': {}", b.name(), bad.join("; ")))
        }
    }

    /// A backend with every cap, every method answering — what a decorator is
    /// wrapped around to prove it forwards everything.
    pub struct Everything {
        pub name: String,
        pub caps: Caps,
    }

    impl Everything {
        pub fn new(name: &str) -> Everything {
            Everything {
                name: name.to_string(),
                caps: Caps::ALL,
            }
        }
        /// The same backend, answering only the core.
        pub fn core_only(name: &str) -> CoreOnly {
            CoreOnly {
                name: name.to_string(),
            }
        }
    }

    impl RepositoryBackendTrait for Everything {
        fn name(&self) -> &str {
            &self.name
        }
        fn format(&self) -> ArtifactFormat {
            ArtifactFormat::Raw
        }
        fn is_writable(&self) -> bool {
            true
        }
        fn fetch(&self, _: &ArtifactId) -> anyhow::Result<Option<Bytes>> {
            Ok(None)
        }
        fn put(&self, _: &ArtifactId, _: &[u8]) -> anyhow::Result<()> {
            Ok(())
        }
        fn handle_http2_request(&self, _: &str, _: &str, _: &[u8]) -> anyhow::Result<HttpReply> {
            Ok((404, Vec::new(), Vec::new()))
        }
        fn caps(&self) -> Caps {
            self.caps
        }
        fn list(&self, _: Option<&str>, _: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
            Ok(Vec::new())
        }
        fn archive(&self) -> anyhow::Result<Archive> {
            Ok(Archive {
                name: self.name.clone(),
                files: Vec::new(),
            })
        }
        fn group_members(&self) -> anyhow::Result<Vec<String>> {
            Ok(Vec::new())
        }
        fn fetch_chunks(&self, _: &ArtifactId, _: usize) -> anyhow::Result<Option<ChunkIter>> {
            Ok(None)
        }
        fn put_from_reader(
            &self,
            _: &ArtifactId,
            body: &mut dyn std::io::Read,
            _: Option<u64>,
        ) -> anyhow::Result<()> {
            let mut sink = Vec::new();
            body.read_to_end(&mut sink)?;
            Ok(())
        }
        fn handle_http2_upload(
            &self,
            _: &str,
            _: &str,
            _: &mut dyn std::io::Read,
            _: Option<u64>,
        ) -> anyhow::Result<Option<HttpReply>> {
            Ok(None)
        }
        fn coordinate_for_path(&self, _: &str) -> anyhow::Result<Option<ArtifactId>> {
            Ok(None)
        }
        fn delete_artifact(&self, _: &ArtifactId, _: &str) -> anyhow::Result<()> {
            anyhow::bail!("not found")
        }
    }

    /// A backend that answers only the core.
    pub struct CoreOnly {
        pub name: String,
    }

    impl RepositoryBackendTrait for CoreOnly {
        fn name(&self) -> &str {
            &self.name
        }
        fn format(&self) -> ArtifactFormat {
            ArtifactFormat::Raw
        }
        fn is_writable(&self) -> bool {
            false
        }
        fn fetch(&self, _: &ArtifactId) -> anyhow::Result<Option<Bytes>> {
            Ok(None)
        }
        fn put(&self, _: &ArtifactId, _: &[u8]) -> anyhow::Result<()> {
            anyhow::bail!("read-only")
        }
        fn handle_http2_request(&self, _: &str, _: &str, _: &[u8]) -> anyhow::Result<HttpReply> {
            Ok((404, Vec::new(), Vec::new()))
        }
        fn caps(&self) -> Caps {
            Caps::NONE
        }
    }
}

#[cfg(test)]
mod tests {
    use super::conformance::*;
    use super::*;

    /// A decorator that changes nothing: the blanket impl alone.
    struct Pass(Box<dyn RepositoryBackendTrait>);
    impl Decorator for Pass {
        fn name(&self) -> &str {
            self.0.name()
        }
        fn format(&self) -> ArtifactFormat {
            self.0.format()
        }
        fn is_writable(&self) -> bool {
            self.0.is_writable()
        }
        fn caps(&self) -> Caps {
            self.0.caps()
        }
        fn around<T>(
            &self,
            _: Access,
            call: impl FnOnce(&dyn RepositoryBackendTrait) -> anyhow::Result<T>,
        ) -> anyhow::Result<T> {
            call(self.0.as_ref())
        }
    }

    #[test]
    fn the_reference_backends_are_honest() {
        check(&Everything::new("all")).unwrap();
        check(&Everything::core_only("core")).unwrap();
    }

    /// ★ The blanket impl forwards EVERY unlocked method: around a backend with
    /// every cap, a pass-through decorator answers every one of them.
    #[test]
    fn a_decorator_forwards_every_method_by_construction() {
        check(&Pass(Box::new(Everything::new("all")))).unwrap();
        check(&Pass(Box::new(Everything::core_only("core")))).unwrap();
    }

    /// RED both ways: a cap without its method, a method without its cap.
    #[test]
    fn a_cap_without_its_method_and_a_method_without_its_cap_both_fail() {
        let liar = Everything::core_only("liar");
        struct Claims(CoreOnly);
        impl Decorator for Claims {
            fn name(&self) -> &str {
                &self.0.name
            }
            fn format(&self) -> ArtifactFormat {
                ArtifactFormat::Raw
            }
            fn is_writable(&self) -> bool {
                false
            }
            fn caps(&self) -> Caps {
                Caps::LISTING
            }
            fn around<T>(
                &self,
                _: Access,
                call: impl FnOnce(&dyn RepositoryBackendTrait) -> anyhow::Result<T>,
            ) -> anyhow::Result<T> {
                call(&self.0)
            }
        }
        let e = check(&Claims(liar)).unwrap_err();
        assert!(e.contains("Listing is claimed"), "{e}");

        let hidden = Everything {
            name: "hidden".into(),
            caps: Caps::NONE,
        };
        let e = check(&hidden).unwrap_err();
        assert!(e.contains("Delete is not claimed"), "{e}");
    }

    #[test]
    fn the_dyn_helpers_fall_back_honestly() {
        let core: Box<dyn RepositoryBackendTrait> = Box::new(Everything::core_only("core"));
        assert!(core.read_chunks(&probe_id(), 4).unwrap().is_none());
        assert!(
            core.write_from_reader(&probe_id(), &mut &b"x"[..], None)
                .is_err(),
            "read-only put"
        );
        assert_eq!(core.coordinate("/core/x"), None);
        assert!(core.members().is_empty());
        assert!(is_unsupported(&core.list(None, 0).unwrap_err()));
    }

    #[test]
    fn an_archive_is_one_answer_for_paths_and_stats() {
        let a = Archive {
            name: "g".into(),
            files: vec![("a/1".into(), 3), ("b/2".into(), 4), ("a/1".into(), 3)],
        };
        assert_eq!(
            a.info(),
            ArchiveInfo {
                file_count: 3,
                total_uncompressed_bytes: 10,
                archive_path: "g".into()
            }
        );
        assert_eq!(
            a.clone().paths(None),
            vec!["a/1".to_string(), "b/2".to_string()]
        );
        assert_eq!(a.paths(Some("b")), vec!["b/2".to_string()]);
    }
}