use std::path::{Path, PathBuf}; #[derive(Debug, thiserror::Error)] pub enum RsyncFetchError { #[error("rsync fetch error: {0}")] Fetch(String), } pub type RsyncFetchResult = Result; /// Fetch repository objects from a publication point. /// /// v1: this is intentionally abstract so unit tests can use a mock, and later we can /// back it by calling the system `rsync` binary (RFC 6481 §5; RFC 8182 §3.4.5). pub trait RsyncFetcher { /// Return a list of objects as `(rsync_uri, bytes)` pairs. fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult)>>; } /// A simple "rsync" implementation backed by a local directory. /// /// This is primarily meant for offline tests and fixtures. The key generation mimics rsync URIs: /// `rsync_base_uri` + relative path (with `/` separators). pub struct LocalDirRsyncFetcher { pub root_dir: PathBuf, } impl LocalDirRsyncFetcher { pub fn new(root_dir: impl Into) -> Self { Self { root_dir: root_dir.into(), } } } impl RsyncFetcher for LocalDirRsyncFetcher { fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult)>> { let base = normalize_rsync_base_uri(rsync_base_uri); let mut out = Vec::new(); walk_dir_collect(&self.root_dir, &self.root_dir, &base, &mut out) .map_err(|e| RsyncFetchError::Fetch(e))?; Ok(out) } } fn walk_dir_collect( root: &Path, current: &Path, rsync_base_uri: &str, out: &mut Vec<(String, Vec)>, ) -> Result<(), String> { let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?; for entry in rd { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); let meta = entry.metadata().map_err(|e| e.to_string())?; if meta.is_dir() { walk_dir_collect(root, &path, rsync_base_uri, out)?; continue; } if !meta.is_file() { continue; } let rel = path .strip_prefix(root) .map_err(|e| e.to_string())? .to_string_lossy() .replace('\\', "/"); let uri = format!("{rsync_base_uri}{rel}"); let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; out.push((uri, bytes)); } Ok(()) } fn normalize_rsync_base_uri(s: &str) -> String { if s.ends_with('/') { s.to_string() } else { format!("{s}/") } } #[cfg(test)] mod tests { use super::*; #[test] fn local_dir_rsync_fetcher_collects_files_and_normalizes_base_uri() { let tmp = tempfile::tempdir().expect("tempdir"); std::fs::create_dir_all(tmp.path().join("nested")).expect("mkdir"); std::fs::write(tmp.path().join("a.mft"), b"a").expect("write"); std::fs::write(tmp.path().join("nested").join("b.roa"), b"b").expect("write"); let f = LocalDirRsyncFetcher::new(tmp.path()); let mut objects = f .fetch_objects("rsync://example.net/repo") .expect("fetch_objects"); objects.sort_by(|(a, _), (b, _)| a.cmp(b)); assert_eq!(objects.len(), 2); assert_eq!(objects[0].0, "rsync://example.net/repo/a.mft"); assert_eq!(objects[0].1, b"a"); assert_eq!(objects[1].0, "rsync://example.net/repo/nested/b.roa"); assert_eq!(objects[1].1, b"b"); } #[test] fn local_dir_rsync_fetcher_reports_read_dir_errors() { let tmp = tempfile::tempdir().expect("tempdir"); let missing = tmp.path().join("missing"); let f = LocalDirRsyncFetcher::new(missing); let err = f.fetch_objects("rsync://example.net/repo").unwrap_err(); match err { RsyncFetchError::Fetch(msg) => assert!(!msg.is_empty()), } } }