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