server/repository/znippy-conda/src/lib.rs
//! Conda repository backend: serves a real per-subdir `repodata.json` index plus
//! the packages, synthesized from a read-only znippy archive (sibling to the
//! deb/rpm/npm/gem backends). The coord-authoritative path resolves through the
//! archive's typed **conda view** ([`ZnippyArchive::as_conda`]) — name/version/
//! build/subdir come from each package's parsed `info/index.json`, not a filename
//! heuristic — so `repodata` and `package_via_view` serve classic
//! `{name}-{version}-{build}.tar.bz2` coords exactly. A `with_reader` fallback
//! has no view and serves by exact path only (empty `packages` map). Read-only:
//! `put`/`is_writable` refuse writes.
//!
//! Two invariants that bite: `repodata.json` is cached per-subdir behind a Mutex
//! because building it decompresses and re-hashes every package — without the
//! cache each request is a DoS-amplification vector (mamba hits repodata first).
//! The cache key is the subdir RESOLVED against the set the archive actually
//! carries ([`CondaRepoZnippy::known_subdirs`]); anything else 404s before a byte
//! of work. Keyed on the raw first path segment it was no cache at all — every
//! invented `/{subdir}/repodata.json` missed, re-hashed the whole channel, and
//! grew the map, so one unauthenticated client could loop on it.
//! And the `sha256` key MUST carry a bare 64-hex SHA-256 (conda/mamba abort on a
//! checksum mismatch); the znippy blake3 rides in a separate non-standard `blake3`
//! key, never in `sha256`.
use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::Result;
use traits::znippy_base::{not_found, ok};
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;
/// znippy-backed Conda channel.
///
/// conda/mamba fetch the per-subdir index then the packages:
/// * `GET /{repo}/{subdir}/repodata.json` → package index
/// * `GET /{repo}/{subdir}/{pkg}.tar.bz2` → package (classic)
/// * `GET /{repo}/{subdir}/{pkg}.conda` → package (new format)
///
/// When backed by a concrete [`ZnippyArchive`] the repo resolves through the typed
/// **conda view** ([`ZnippyArchive::as_conda`]): the package `name`, `version`,
/// `build`, and `subdir` are read from the index columns the plugin parsed out of
/// each package's `info/index.json`. The view is built once (cached behind the
/// archive's `OnceLock`) and reused.
///
/// **R4 fix:** `repodata.json` is now a REAL index built from the view's
/// authoritative coords — a `packages` map keyed by filename with
/// `{name, version, build, subdir, size, sha256}` per the conda repodata schema —
/// not the old dummy `{"packages":{},"packages.conda":{}}` stub. (See
/// [`CondaRepoZnippy::repodata`].)
pub struct CondaRepoZnippy {
base: ZnippyBase,
/// Per-subdir `repodata.json` cache (M8). The archive is immutable, so the
/// index for a given subdir is computed once (decompress + blake3 over every
/// package) and reused — without this each `repodata.json` request re-hashes
/// the whole channel, a DoS-amplification vector (mamba hits it first).
///
/// Keyed by a subdir that [`CondaRepoZnippy::known_subdirs`] has already
/// accepted, so it cannot be grown (or bypassed) by inventing subdir names.
repodata_cache: Mutex<HashMap<String, Arc<Vec<u8>>>>,
/// The subdirs this channel actually serves — derived once from the archive
/// (the typed view's `subdir` column plus the top-level directories), and the
/// only keys `repodata_cache` can ever hold.
subdirs_cache: OnceLock<BTreeSet<String>>,
}
impl CondaRepoZnippy {
pub fn new(name: String) -> Self {
Self {
base: ZnippyBase::new(name),
repodata_cache: Mutex::new(HashMap::new()),
subdirs_cache: OnceLock::new(),
}
}
/// Backed by a specific znippy archive file. Holds the open archive so the
/// typed conda view is built once and reused.
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
Ok(Self {
base: ZnippyBase::with_archive(name, &archive_path)?,
repodata_cache: Mutex::new(HashMap::new()),
subdirs_cache: OnceLock::new(),
})
}
/// Backed by any ZnippyReader. No typed conda view; serves by exact path only.
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self {
base: ZnippyBase::with_reader(name, reader),
repodata_cache: Mutex::new(HashMap::new()),
subdirs_cache: OnceLock::new(),
}
}
pub fn list_files(&self) -> Vec<String> {
self.base.list_files()
}
pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
self.base.get_file(relative_path)
}
/// The package filename for a coord. Classic conda packages compress to
/// `{name}-{version}-{build}.tar.bz2`; that's the form this view serves.
fn package_filename(name: &str, version: &str, build: &str) -> String {
format!("{name}-{version}-{build}.tar.bz2")
}
/// The subdirs this channel serves: every `subdir` the typed view reports,
/// every top-level directory in the archive, and always `noarch` (conda and
/// mamba fetch `noarch/repodata.json` unconditionally, so a channel that
/// carries no noarch package still owes them a valid empty index). Derived
/// once — the archive is immutable — and the ONLY keys `repodata` will accept.
fn known_subdirs(&self) -> &BTreeSet<String> {
self.subdirs_cache.get_or_init(|| {
let mut out = BTreeSet::new();
out.insert("noarch".to_string());
if let Some(archive) = self.base.typed() {
if let Some(conda) = archive.as_conda() {
for (_n, _v, _b, subdir) in conda.list() {
if !subdir.is_empty() {
out.insert(subdir);
}
}
}
}
for path in self.list_files() {
if let Some((top, _rest)) = path.split_once('/') {
if !top.is_empty() {
out.insert(top.to_string());
}
}
}
out
})
}
/// Cached `repodata.json` for `subdir` (M8). Computes once per subdir and
/// serves clones thereafter.
///
/// `None` for a subdir this channel does not carry — the caller 404s it. That
/// resolution is what bounds the cache: the key is one of the archive's own
/// subdirs, never whatever first path segment happened to arrive.
fn repodata(&self, subdir: &str) -> Option<Arc<Vec<u8>>> {
if !self.known_subdirs().contains(subdir) {
return None;
}
if let Ok(cache) = self.repodata_cache.lock() {
if let Some(hit) = cache.get(subdir) {
return Some(Arc::clone(hit));
}
}
let built = Arc::new(self.build_repodata(subdir));
if let Ok(mut cache) = self.repodata_cache.lock() {
cache.insert(subdir.to_string(), Arc::clone(&built));
}
Some(built)
}
/// Build the conda **repodata.json** for `subdir` from the view's authoritative
/// coords (R4). Shape (conda repodata schema):
///
/// ```json
/// {
/// "info": {"subdir": "linux-64"},
/// "packages": {
/// "numpy-1.26.0-py311h1234567_0.tar.bz2": {
/// "name": "numpy", "version": "1.26.0", "build": "py311h1234567_0",
/// "subdir": "linux-64", "size": 12345, "sha256": "blake3:…"
/// }
/// },
/// "packages.conda": {}
/// }
/// ```
///
/// `size` is the uncompressed size from the index. A real channel publishes the
/// package's SHA-256; with no sha column in the index we publish the znippy
/// **blake3** of the served bytes, prefixed `blake3:` so the value is
/// unambiguous and verifiable against the served bytes (documented divergence).
fn build_repodata(&self, subdir: &str) -> Vec<u8> {
// Built with `serde_json` so the (attacker-controlled) name/version/build/
// subdir from `info/index.json` are JSON-escaped, never interpolated raw.
let mut packages = serde_json::Map::new();
if let Some(archive) = self.base.typed() {
if let Some(conda) = archive.as_conda() {
for (name, version, build, pkg_subdir) in conda.list() {
// Filter to the requested subdir; treat an empty stored subdir
// (older/unknown) as matching any request.
if !pkg_subdir.is_empty() && pkg_subdir != subdir {
continue;
}
let Some(pkg) = conda.get_exact(&name, &version, &build, &pkg_subdir) else {
continue;
};
let filename = Self::package_filename(&name, &version, &build);
let size = pkg.size();
// The `sha256` key MUST carry a real SHA-256 (bare 64-hex):
// conda/mamba abort on a checksum mismatch. blake3 is kept
// under a separate non-standard key for content-addressing.
let bytes = pkg.bytes().ok();
// REUSE-law: the ONE shared content-hash primitive (edda
// `nornir_hash`) — conda/mamba abort on a `sha256` mismatch, so
// this must be the byte-identical bare 64-hex of the served bytes.
let sha256 = bytes
.as_ref()
.map(|b| nornir_hash::sha256_hex(b))
.unwrap_or_default();
let blake3_hex = bytes
.as_ref()
.map(|b| blake3::hash(b).to_hex().to_string())
.unwrap_or_default();
let effective_subdir = if pkg_subdir.is_empty() {
subdir
} else {
pkg_subdir.as_str()
};
packages.insert(
filename,
serde_json::json!({
"name": name,
"version": version,
"build": build,
"subdir": effective_subdir,
"size": size,
"sha256": sha256,
"blake3": blake3_hex,
}),
);
}
}
}
// serde_json's `Map` is key-sorted (no `preserve_order` feature), so the
// packages stay in filename order — and every value is escaped.
let doc = serde_json::json!({
"info": { "subdir": subdir },
"packages": packages,
"packages.conda": {},
});
serde_json::to_vec(&doc).unwrap_or_default()
}
/// Resolve a package request via the view: `subdir` + `filename`
/// (`{name}-{version}-{build}.tar.bz2`). Matches against the view's
/// authoritative coords (so the build string resolves without a filename
/// heuristic). `None` if not a `.tar.bz2` coord in the view.
fn package_via_view(&self, subdir: &str, filename: &str) -> Option<Vec<u8>> {
let archive = self.base.typed()?;
let conda = archive.as_conda()?;
for (name, version, build, pkg_subdir) in conda.list() {
if !pkg_subdir.is_empty() && pkg_subdir != subdir {
continue;
}
if Self::package_filename(&name, &version, &build) == filename {
return conda
.get_exact(&name, &version, &build, &pkg_subdir)
.and_then(|p| p.into_bytes().ok());
}
}
None
}
}
fn content_type_for(path: &str) -> &'static str {
if path.ends_with(".json") {
"application/json"
} else if path.ends_with(".tar.bz2") {
"application/x-bzip2"
} else {
"application/octet-stream"
}
}
impl RepositoryBackendTrait for CondaRepoZnippy {
traits::znippy_read_only!(base);
/// A package download in pieces (lane HOL-RSS part 2): the archive file the whole-body
/// door reads (the view's, else the exact path). `repodata.json` 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();
if remainder.is_empty() || matches!(parts.as_slice(), [_, "repodata.json"]) {
return Ok(None);
}
// the view's package first, then the exact path, as the whole-body door reads them
if let [subdir, file] = parts.as_slice() {
let via_view = self
.base
.typed()
.and_then(|a| a.as_conda())
.and_then(|conda| {
conda
.list()
.into_iter()
.filter(|(_, _, _, s)| s.is_empty() || s == subdir)
.find(|(n, v, b, _)| Self::package_filename(n, v, b) == *file)
.and_then(|(n, v, b, s)| conda.get_exact(&n, &v, &b, &s))
.map(|pkg| pkg.relative_path().to_string())
});
if let Some(rel) = via_view {
return self
.base
.stream_get(&rel, content_type_for(file), piece_bytes);
}
}
self.base
.stream_get(remainder, content_type_for(remainder), 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!("conda repo znippy handle_http2_request.suburl={}", suburl);
let remainder = self.base.strip_repo(suburl);
if remainder.is_empty() {
return Ok(not_found("Not found"));
}
let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
// `{subdir}/repodata.json` → REAL index built from the view (R4), but only
// for a subdir the channel actually carries; an invented one 404s here,
// before any decompress/hash work and without touching the cache.
if let [subdir, "repodata.json"] = parts.as_slice() {
return match self.repodata(subdir) {
Some(body) => Ok((
200,
vec![("Content-Type".into(), "application/json".into())],
body.as_ref().clone(),
)),
None => Ok(not_found("Unknown subdir")),
};
}
// `{subdir}/{pkg}.tar.bz2` → view-driven serve, then exact-path fallback.
if let [subdir, file] = parts.as_slice() {
if let Some(data) = self.package_via_view(subdir, file) {
return Ok(ok(content_type_for(file), data));
}
}
match self.get_file(remainder) {
Ok(data) => Ok(ok(content_type_for(remainder), data)),
Err(_) => Ok(not_found("Not found in archive")),
}
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Conda
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
// Resolve through the typed conda view: name/version come from the index
// columns (info/index.json). The subdir rides in `id.namespace`
// (e.g. "linux-64"); default "noarch". `get` returns the first build match.
if let Some(archive) = self.base.typed() {
if let Some(conda) = archive.as_conda() {
let subdir = id.namespace.as_deref().filter(|s| !s.is_empty());
let pkg = match subdir {
Some(s) => conda
.list()
.into_iter()
.find(|(n, v, _b, sd)| n == &id.name && v == &id.version && sd == s)
.and_then(|(n, v, b, sd)| conda.get_exact(&n, &v, &b, &sd)),
None => conda.get(&id.name, &id.version),
};
if let Some(pkg) = pkg {
return Ok(Some(traits::Bytes::from(pkg.into_bytes()?)));
}
return Ok(None);
}
}
// Fallback for non-archive readers: legacy filename path build.
let subdir = id.namespace.as_deref().unwrap_or("noarch");
for ext in [".conda", ".tar.bz2"] {
let path = format!("{}/{}-{}{}", subdir, id.name, id.version, ext);
if let Ok(data) = self.get_file(&path) {
return Ok(Some(traits::Bytes::from(data)));
}
}
Ok(None)
}
/// List packages in the archive via the view — authoritative `(name, version,
/// build, subdir)` coords. The subdir rides in the entry's `namespace`. Empty
/// when no archive configured.
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
if let Some(archive) = self.base.typed() {
if let Some(conda) = archive.as_conda() {
// ONE index read for the whole listing; every token after it is
// memory, and no chunk of any artifact is touched. See
// [`traits::ArchiveTokens`].
let tokens = self.base.tokens().open();
let mut out = Vec::new();
for (name, version, build, subdir) in conda.list() {
if let Some(f) = name_filter {
if !name.contains(f) {
continue;
}
}
// The package handle names its own archive row
// (`relative_path`), which is the key the lookup sub-index
// answers a change token for. Size and token come off the
// same handle; neither reads an artifact byte.
let (size_bytes, change_token) = conda
.get_exact(&name, &version, &build, &subdir)
.map(|p| {
let token = tokens.as_ref().and_then(|t| t.token(p.relative_path()));
(p.size() as i64, token)
})
.unwrap_or((0, None));
let namespace = if subdir.is_empty() {
None
} else {
Some(subdir)
};
out.push(
traits::ArtifactEntry {
id: ArtifactId {
namespace,
name,
version,
},
size_bytes,
content_type: "application/x-bzip2".into(),
// A znippy file table publishes no mtime and no per-entry digest;
// answering either would mean extracting and hashing every
// artifact on the listing path. Unknown, never a fabricated zero.
// The CHANGE TOKEN below is what the index CAN answer.
..traits::ArtifactEntry::UNKNOWN_PROVENANCE
}
.with_change_token(change_token),
);
if limit != 0 && out.len() >= limit {
break;
}
}
return Ok(out);
}
}
Ok(Vec::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use traits::test_support::MemReader;
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
#[test]
fn test_new() {
let repo = CondaRepoZnippy::new("conda-test".to_string());
assert_eq!(repo.name(), "conda-test");
assert!(!repo.is_writable());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-conda",
"new_name_and_readonly",
repo.name() == "conda-test" && !repo.is_writable(),
&format!("name={} writable={}", repo.name(), repo.is_writable()),
);
}
#[test]
fn test_format() {
let f = CondaRepoZnippy::new("x".into()).format();
assert_eq!(f, ArtifactFormat::Conda);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-conda",
"format_is_conda",
f == ArtifactFormat::Conda,
&format!("format = {f:?}"),
);
}
/// The `repodata.json` cache must be keyed on a subdir the channel actually
/// carries — not on whatever first path segment arrived. Keyed on the raw
/// segment, `GET /{repo}/{anything}/repodata.json` was a guaranteed cache MISS
/// that rebuilt the index (decompress + hash every package) and then GREW the
/// map, so one unauthenticated client could both burn CPU and grow the heap
/// without bound. Unknown subdirs must 404 and leave the cache alone.
#[test]
fn repodata_subdir_is_resolved_against_the_archive() {
let repo = CondaRepoZnippy::with_reader(
"conda-test".to_string(),
MemReader::new([("linux-64/numpy-1.26.0.tar.bz2", b"PAYLOAD".to_vec())]),
);
// The subdir the archive carries resolves.
let (ok_status, _h, ok_body) = repo
.handle_http2_request("GET", "/conda-test/linux-64/repodata.json", &[])
.expect("serve ok");
assert_eq!(ok_status, 200, "a real subdir must still serve");
let parsed: serde_json::Value = serde_json::from_slice(&ok_body).expect("json");
assert_eq!(parsed["info"]["subdir"], serde_json::json!("linux-64"));
// `noarch` is always served (conda/mamba fetch it unconditionally) even
// though this channel carries no noarch package.
let (noarch_status, _h, _b) = repo
.handle_http2_request("GET", "/conda-test/noarch/repodata.json", &[])
.expect("serve ok");
assert_eq!(noarch_status, 200, "noarch must always resolve");
// The attack: a fresh subdir name every time.
let mut invented_statuses = Vec::new();
for i in 0..25 {
let (s, _h, _b) = repo
.handle_http2_request("GET", &format!("/conda-test/subdir-{i}/repodata.json"), &[])
.expect("serve ok");
invented_statuses.push(s);
}
assert!(
invented_statuses.iter().all(|s| *s == 404),
"every invented subdir must 404, got {invented_statuses:?}"
);
// …and the cache holds only the real subdirs that were actually asked for.
let cached = repo.repodata_cache.lock().unwrap();
assert!(
cached.len() <= 2 && cached.keys().all(|k| k == "linux-64" || k == "noarch"),
"cache must be bounded by the archive's own subdirs, got {:?}",
cached.keys().collect::<Vec<_>>()
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-conda",
"repodata_subdir_resolved",
invented_statuses.iter().all(|s| *s == 404) && cached.len() <= 2,
&format!(
"25 invented subdirs -> 404, cache bounded to {} real subdir(s)",
cached.len()
),
);
}
#[test]
fn package_filename_is_name_version_build_tar_bz2() {
assert_eq!(
CondaRepoZnippy::package_filename("numpy", "1.26.0", "py311h1234567_0"),
"numpy-1.26.0-py311h1234567_0.tar.bz2"
);
}
/// End-to-end serve/fetch over a `with_reader`-backed repo (generic reader
/// fallback, no typed conda view). Injects one classic package at the exact
/// archive key the reader-fallback `fetch()` builds
/// (`{subdir}/{name}-{version}.tar.bz2`) and the path `get_file()`/
/// `handle_http2_request` resolve, then asserts: `fetch()` hit/miss, the
/// HTTP/2 package serve (200 + `application/x-bzip2` + bytes) and miss (404),
/// and the real `repodata.json` route (200 + `application/json`, echoing the
/// requested subdir — the packages map is empty here because the generic
/// reader carries no authoritative coord view).
#[test]
fn serves_injected_archive() {
// Exact key the reader-fallback fetch() builds and the HTTP path resolves.
const KEY: &str = "linux-64/numpy-1.26.0.tar.bz2";
let pkg_bytes = b"CONDA-NUMPY-1.26.0-PAYLOAD".to_vec();
let repo = CondaRepoZnippy::with_reader(
"conda-test".to_string(),
MemReader::new([(KEY, pkg_bytes.clone())]),
);
// --- fetch(): hit resolves name+version+subdir → injected bytes.
let hit_id = ArtifactId {
namespace: Some("linux-64".to_string()),
name: "numpy".to_string(),
version: "1.26.0".to_string(),
};
let fetched = repo.fetch(&hit_id).expect("fetch ok");
assert_eq!(fetched.as_deref(), Some(pkg_bytes.as_slice()));
// --- fetch(): miss returns None.
let miss_id = ArtifactId {
namespace: Some("linux-64".to_string()),
name: "nope".to_string(),
version: "9.9.9".to_string(),
};
let fetched_miss = repo.fetch(&miss_id).expect("fetch ok");
assert_eq!(fetched_miss, None);
// --- handle_http2_request(): package hit → 200 + content-type + bytes.
let (status, headers, body) = repo
.handle_http2_request("GET", "/conda-test/linux-64/numpy-1.26.0.tar.bz2", &[])
.expect("serve ok");
let ctype = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
.map(|(_, v)| v.as_str());
assert_eq!(status, 200);
assert_eq!(ctype, Some("application/x-bzip2"));
assert_eq!(body, pkg_bytes);
// --- handle_http2_request(): package miss → 404.
let (miss_status, _miss_headers, _miss_body) = repo
.handle_http2_request("GET", "/conda-test/linux-64/absent-0.0.0.tar.bz2", &[])
.expect("serve ok");
assert_eq!(miss_status, 404);
// --- handle_http2_request(): real repodata.json route → 200 + JSON.
let (rd_status, rd_headers, rd_body) = repo
.handle_http2_request("GET", "/conda-test/linux-64/repodata.json", &[])
.expect("serve ok");
let rd_ctype = rd_headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
.map(|(_, v)| v.as_str());
assert_eq!(rd_status, 200);
assert_eq!(rd_ctype, Some("application/json"));
let rd_json: serde_json::Value =
serde_json::from_slice(&rd_body).expect("repodata is valid json");
assert_eq!(rd_json["info"]["subdir"], serde_json::json!("linux-64"));
assert!(rd_json["packages"].is_object());
#[cfg(feature = "testmatrix")]
{
fstatus(
"znippy-conda",
"fetch_injected_package",
fetched.as_deref() == Some(pkg_bytes.as_slice()) && fetched_miss.is_none(),
&format!("key={KEY} hit_bytes={} miss=None", pkg_bytes.len()),
);
fstatus(
"znippy-conda",
"serve_injected_package",
status == 200 && ctype == Some("application/x-bzip2") && body == pkg_bytes,
&format!("status={status} ctype={ctype:?} bytes={}", body.len()),
);
fstatus(
"znippy-conda",
"serve_missing_package_404",
miss_status == 404,
&format!("status={miss_status}"),
);
fstatus(
"znippy-conda",
"serve_repodata_json",
rd_status == 200
&& rd_ctype == Some("application/json")
&& rd_json["info"]["subdir"] == serde_json::json!("linux-64"),
&format!(
"status={rd_status} ctype={rd_ctype:?} bytes={}",
rd_body.len()
),
);
}
}
/// 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(&CondaRepoZnippy::new("r".into())).unwrap();
traits::backend::conformance::check(&CondaRepoZnippy::with_reader(
"r".into(),
std::sync::Arc::new(One),
))
.unwrap();
}
}