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()]);
}
}