use std::{fs, io::Read, path::Path, thread, time::Duration};
use compress::zlib;
use reqwest::blocking::Client;
use sha1::{Digest, Sha1};
use crate::{
cache::Cache,
common::{CvmfsError, CvmfsResult},
geo::sort_servers_by_geo,
};
const MAX_DOWNLOAD_SIZE: u64 = 1024 * 1024 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(test))]
const BACKOFF_INIT: Duration = Duration::from_secs(2);
#[cfg(not(test))]
const BACKOFF_MAX: Duration = Duration::from_secs(10);
#[cfg(not(test))]
const MAX_RETRIES: u32 = 3;
#[cfg(test)]
const BACKOFF_INIT: Duration = Duration::from_millis(1);
#[cfg(test)]
const BACKOFF_MAX: Duration = Duration::from_millis(2);
#[cfg(test)]
const MAX_RETRIES: u32 = 1;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[derive(Debug)]
pub struct Fetcher {
pub cache: Cache,
pub source: String,
pub mirrors: Vec<String>,
pub proxy: Option<String>,
pub offline: AtomicBool,
pub io_errors: AtomicU64,
}
impl Fetcher {
pub fn new(source: &str, cache_directory: &str, initialize: bool) -> CvmfsResult<Self> {
let path = Path::new(source);
let source = if path.exists() && path.is_dir() {
format!("{}{}", "file://", source)
} else {
source.into()
};
let cache = Cache::new(cache_directory.into())?;
if initialize {
cache.initialize()?;
}
Ok(Self {
cache,
source,
mirrors: Vec::new(),
proxy: None,
offline: AtomicBool::new(false),
io_errors: AtomicU64::new(0),
})
}
pub fn with_mirrors(
sources: &[&str],
cache_directory: &str,
initialize: bool,
) -> CvmfsResult<Self> {
if sources.is_empty() {
return Err(CvmfsError::Generic("at least one source is required".into()));
}
let mut fetcher = Self::new(sources[0], cache_directory, initialize)?;
for &mirror in &sources[1..] {
let path = Path::new(mirror);
let url = if path.exists() && path.is_dir() {
format!("file://{mirror}")
} else {
mirror.into()
};
fetcher.mirrors.push(url);
}
Ok(fetcher)
}
pub fn set_proxy(&mut self, proxy_url: &str) {
self.proxy = Some(proxy_url.to_string());
}
pub fn sort_mirrors_by_geo(&mut self, repo_name: &str) {
let mut all = vec![self.source.clone()];
all.extend(self.mirrors.clone());
if let Ok(sorted) = sort_servers_by_geo(&self.source, repo_name, &all) {
let Some((first, rest)) = sorted.split_first() else {
return;
};
self.source = first.clone();
self.mirrors = rest.to_vec();
}
}
pub fn retrieve_raw_file(&self, file_name: &str) -> CvmfsResult<String> {
let cache_file = self.cache.add(file_name)?;
let cache_str = cache_file.to_str().ok_or(CvmfsError::FileNotFound)?;
let mut last_err = None;
for source in self.all_sources() {
let file_url = Path::join(source.as_ref(), file_name);
match self.download_content_and_store(
cache_str,
file_url.to_str().ok_or(CvmfsError::FileNotFound)?,
) {
Ok(()) => {
return Ok(self
.cache
.get(file_name)
.ok_or(CvmfsError::FileNotFound)?
.to_str()
.ok_or(CvmfsError::FileNotFound)?
.into());
}
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or(CvmfsError::FileNotFound))
}
pub fn retrieve_file(&self, file_name: &str) -> CvmfsResult<String> {
if let Some(cached_file) = self.cache.get(file_name) {
return Ok(cached_file.to_str().ok_or(CvmfsError::FileNotFound)?.into());
}
if self.offline.load(Ordering::Relaxed) {
return Err(CvmfsError::FileNotFound);
}
match self.retrieve_file_from_source(file_name) {
Ok(path) => {
self.offline.store(false, Ordering::Relaxed);
Ok(path)
}
Err(e) => {
self.io_errors.fetch_add(1, Ordering::Relaxed);
if let Some(cached_file) = self.cache.get(file_name) {
log::warn!("network failed, serving {file_name} from stale cache");
self.offline.store(true, Ordering::Relaxed);
return Ok(cached_file.to_str().ok_or(CvmfsError::FileNotFound)?.into());
}
Err(e)
}
}
}
pub fn is_offline(&self) -> bool {
self.offline.load(Ordering::Relaxed)
}
pub fn io_error_count(&self) -> u64 {
self.io_errors.load(Ordering::Relaxed)
}
fn retrieve_file_from_source(&self, file_name: &str) -> CvmfsResult<String> {
let cached_file = self.cache.add(file_name)?;
let cache_str = cached_file.to_str().ok_or(CvmfsError::FileNotFound)?;
let mut last_err = None;
for source in self.all_sources() {
let file_url = Path::join(source.as_ref(), file_name);
match self.download_content_and_decompress(
cache_str,
file_url.to_str().ok_or(CvmfsError::FileNotFound)?,
) {
Ok(()) => {
return match self.cache.get(file_name) {
None => Err(CvmfsError::FileNotFound),
Some(file) => Ok(file.to_str().ok_or(CvmfsError::FileNotFound)?.into()),
};
}
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or(CvmfsError::FileNotFound))
}
fn all_sources(&self) -> impl Iterator<Item = &str> {
std::iter::once(self.source.as_str()).chain(self.mirrors.iter().map(String::as_str))
}
fn build_client(&self) -> CvmfsResult<Client> {
let mut builder = Client::builder().timeout(REQUEST_TIMEOUT);
if let Some(proxy_url) = &self.proxy {
builder = builder.proxy(
reqwest::Proxy::all(proxy_url)
.map_err(|e| CvmfsError::IO(format!("invalid proxy: {e}")))?,
);
}
Ok(builder.build()?)
}
fn validated_get(&self, file_url: &str) -> CvmfsResult<Vec<u8>> {
let client = self.build_client()?;
let mut last_err = None;
let mut delay = BACKOFF_INIT;
for _ in 0..=MAX_RETRIES {
match client.get(file_url).send() {
Ok(response) => {
if !response.status().is_success() {
last_err = Some(CvmfsError::IO(format!(
"HTTP {} for {}",
response.status(),
file_url
)));
} else if let Some(len) =
response.content_length().filter(|&l| l > MAX_DOWNLOAD_SIZE)
{
return Err(CvmfsError::IO(format!("response too large: {len} bytes")));
} else {
return Ok(response.bytes()?.to_vec());
}
}
Err(e) => last_err = Some(CvmfsError::from(e)),
}
thread::sleep(delay);
delay = (delay * 2).min(BACKOFF_MAX);
}
Err(last_err.unwrap_or(CvmfsError::FileNotFound))
}
fn download_content_and_decompress(
&self,
cached_file: &str,
file_url: &str,
) -> CvmfsResult<()> {
let file_bytes = self.validated_get(file_url)?;
Self::decompress(&file_bytes, cached_file)?;
Ok(())
}
fn download_content_and_store(&self, cached_file: &str, file_url: &str) -> CvmfsResult<()> {
let content = self.validated_get(file_url)?;
fs::write(cached_file, content)?;
Ok(())
}
fn decompress(compressed_bytes: &[u8], cached_file: &str) -> CvmfsResult<()> {
let decompressed = Self::try_decompress_zlib(compressed_bytes)
.or_else(|_| Self::try_decompress_zstd(compressed_bytes))
.or_else(|_| Self::try_decompress_lz4(compressed_bytes))?;
fs::write(cached_file, decompressed)?;
Ok(())
}
fn try_decompress_zlib(data: &[u8]) -> CvmfsResult<Vec<u8>> {
let mut decompressed = Vec::new();
zlib::Decoder::new(data).read_to_end(&mut decompressed)?;
Ok(decompressed)
}
fn try_decompress_zstd(data: &[u8]) -> CvmfsResult<Vec<u8>> {
let decompressed =
zstd::stream::decode_all(data).map_err(|e| CvmfsError::IO(format!("zstd: {e}")))?;
Ok(decompressed)
}
pub fn verify_hash(data: &[u8], expected_hash: &str) -> CvmfsResult<()> {
let mut hasher = Sha1::new();
hasher.update(data);
let computed: String = hex::encode(hasher.finalize());
let expected_clean = expected_hash.split('-').next().unwrap_or(expected_hash);
if computed != expected_clean {
return Err(CvmfsError::IO(format!(
"hash mismatch: expected {expected_clean}, got {computed}"
)));
}
Ok(())
}
pub fn retrieve_file_verified(
&self,
file_name: &str,
expected_hash: &str,
) -> CvmfsResult<String> {
let path = self.retrieve_file(file_name)?;
let data = fs::read(&path)?;
Self::verify_hash(&data, expected_hash)?;
Ok(path)
}
fn try_decompress_lz4(data: &[u8]) -> CvmfsResult<Vec<u8>> {
let decompressed = lz4_flex::frame::FrameDecoder::new(data);
let mut buf = Vec::new();
std::io::BufReader::new(decompressed)
.read_to_end(&mut buf)
.map_err(|e| CvmfsError::IO(format!("lz4: {e}")))?;
Ok(buf)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_cache(name: &str) -> String {
let dir =
std::env::temp_dir().join(format!("cvmfs_fetcher_{}_{}", name, std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir.to_str().unwrap().to_string()
}
#[test]
fn try_decompress_zlib_valid() {
use std::io::Write;
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(b"hello world").unwrap();
let compressed = encoder.finish().unwrap();
let result = Fetcher::try_decompress_zlib(&compressed).unwrap();
assert_eq!(result, b"hello world");
}
#[test]
fn try_decompress_zlib_invalid() {
let result = Fetcher::try_decompress_zlib(b"not zlib data");
assert!(result.is_err());
}
#[test]
fn try_decompress_zstd_valid() {
let data = b"hello zstd world";
let compressed = zstd::stream::encode_all(&data[..], 3).unwrap();
let result = Fetcher::try_decompress_zstd(&compressed).unwrap();
assert_eq!(result, data);
}
#[test]
fn try_decompress_zstd_invalid() {
let result = Fetcher::try_decompress_zstd(b"not zstd data");
assert!(result.is_err());
}
#[test]
fn try_decompress_lz4_valid() {
use lz4_flex::frame::FrameEncoder;
let mut encoder = FrameEncoder::new(Vec::new());
std::io::Write::write_all(&mut encoder, b"hello lz4").unwrap();
let compressed = encoder.finish().unwrap();
let result = Fetcher::try_decompress_lz4(&compressed).unwrap();
assert_eq!(result, b"hello lz4");
}
#[test]
fn try_decompress_lz4_invalid() {
let result = Fetcher::try_decompress_lz4(b"not lz4 data");
assert!(result.is_err());
}
#[test]
fn decompress_tries_all_formats() {
use std::io::Write;
let cache_dir = tmp_cache("decompress");
let output = format!("{cache_dir}/output");
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(b"zlib content").unwrap();
let compressed = encoder.finish().unwrap();
Fetcher::decompress(&compressed, &output).unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "zlib content");
let compressed = zstd::stream::encode_all(&b"zstd content"[..], 3).unwrap();
Fetcher::decompress(&compressed, &output).unwrap();
assert_eq!(fs::read_to_string(&output).unwrap(), "zstd content");
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn decompress_all_formats_fail() {
let cache_dir = tmp_cache("decompress_fail");
let output = format!("{cache_dir}/output");
let result = Fetcher::decompress(b"garbage", &output);
assert!(result.is_err());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn verify_hash_correct() {
let data = b"test data for hashing";
let mut hasher = Sha1::new();
hasher.update(data);
let hash = hex::encode(hasher.finalize());
Fetcher::verify_hash(data, &hash).unwrap();
}
#[test]
fn verify_hash_with_suffix() {
let data = b"test";
let mut hasher = Sha1::new();
hasher.update(data);
let hash = format!("{}-rmd160", hex::encode(hasher.finalize()));
Fetcher::verify_hash(data, &hash).unwrap();
}
#[test]
fn verify_hash_mismatch() {
let result = Fetcher::verify_hash(b"data", "0000000000000000000000000000000000000000");
assert!(result.is_err());
}
#[test]
fn set_proxy_stores_value() {
let cache_dir = tmp_cache("proxy");
let mut fetcher = Fetcher::new("http://example.com", &cache_dir, false).unwrap();
assert!(fetcher.proxy.is_none());
fetcher.set_proxy("http://proxy:8080");
assert_eq!(fetcher.proxy.as_deref(), Some("http://proxy:8080"));
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn all_sources_includes_mirrors() {
let cache_dir = tmp_cache("sources");
let fetcher = Fetcher::with_mirrors(
&["http://a.com", "http://b.com", "http://c.com"],
&cache_dir,
false,
)
.unwrap();
let sources: Vec<&str> = fetcher.all_sources().collect();
assert_eq!(sources, vec!["http://a.com", "http://b.com", "http://c.com"]);
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn with_mirrors_empty_errors() {
let cache_dir = tmp_cache("mirrors_empty");
fs::create_dir_all(&cache_dir).unwrap();
let result = Fetcher::with_mirrors(&[], &cache_dir, false);
assert!(result.is_err());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn build_client_no_proxy() {
let cache_dir = tmp_cache("client_noproxy");
let fetcher = Fetcher::new("http://example.com", &cache_dir, false).unwrap();
let client = fetcher.build_client();
assert!(client.is_ok());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn build_client_with_proxy() {
let cache_dir = tmp_cache("client_proxy");
let mut fetcher = Fetcher::new("http://example.com", &cache_dir, false).unwrap();
fetcher.set_proxy("http://proxy:3128");
let client = fetcher.build_client();
assert!(client.is_ok());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn new_with_local_dir() {
let cache_dir = tmp_cache("local_src");
let src_dir = tmp_cache("local_repo");
let fetcher = Fetcher::new(&src_dir, &cache_dir, false).unwrap();
assert!(fetcher.source.starts_with("file://"));
fs::remove_dir_all(&cache_dir).ok();
fs::remove_dir_all(&src_dir).ok();
}
#[test]
fn offline_and_io_errors_default() {
let cache_dir = tmp_cache("defaults");
let fetcher = Fetcher::new("http://example.com", &cache_dir, false).unwrap();
assert!(!fetcher.is_offline());
assert_eq!(fetcher.io_error_count(), 0);
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn retrieve_file_offline_returns_error() {
let cache_dir = tmp_cache("offline");
let fetcher = Fetcher::new("http://example.com", &cache_dir, true).unwrap();
fetcher.offline.store(true, Ordering::Relaxed);
let result = fetcher.retrieve_file("nonexistent");
assert!(result.is_err());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn retrieve_file_cached_returns_path() {
let cache_dir = tmp_cache("cached");
let fetcher = Fetcher::new("http://example.com", &cache_dir, true).unwrap();
let file_path = format!("{cache_dir}/testfile");
fs::write(&file_path, b"cached content").unwrap();
let result = fetcher.retrieve_file("testfile").unwrap();
assert!(result.contains("testfile"));
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn download_content_and_store_writes_file() {
let cache_dir = tmp_cache("store");
let fetcher =
Fetcher::new("http://cvmfs-stratum-one.cern.ch/opt/boss", &cache_dir, true).unwrap();
let output = format!("{cache_dir}/.cvmfspublished");
fetcher
.download_content_and_store(
&output,
"http://cvmfs-stratum-one.cern.ch/opt/boss/.cvmfspublished",
)
.unwrap();
assert!(Path::new(&output).exists());
assert!(fs::read(&output).unwrap().len() > 10);
fs::remove_dir_all(&cache_dir).ok();
}
use std::{
io::{Read as IoRead, Write as IoWrite},
net::TcpListener,
};
fn http_response(body: &[u8], status_line: &str) -> Vec<u8> {
let mut resp = format!(
"HTTP/1.1 {status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
resp.extend_from_slice(body);
resp
}
fn http_response_with_length(body: &[u8], status_line: &str, declared_len: u64) -> Vec<u8> {
let mut resp = format!(
"HTTP/1.1 {status_line}\r\nContent-Length: {declared_len}\r\nConnection: close\r\n\r\n"
)
.into_bytes();
resp.extend_from_slice(body);
resp
}
fn spawn_responder(responses: Vec<Vec<u8>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
for resp in responses {
let Ok((mut stream, _)) = listener.accept() else { return };
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let _ = stream.write_all(&resp);
}
});
format!("http://{addr}")
}
fn zlib_compress(data: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
#[test]
fn validated_get_http_error_retries_and_fails() {
let cache_dir = tmp_cache("get_500");
let body = http_response(b"err", "500 Internal Server Error");
let url = spawn_responder(vec![body.clone(), body.clone()]);
let fetcher = Fetcher::new(&url, &cache_dir, false).unwrap();
let result = fetcher.validated_get(&url);
assert!(result.is_err());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn validated_get_too_large_errors_fast() {
let cache_dir = tmp_cache("get_big");
let body = http_response_with_length(b"x", "200 OK", MAX_DOWNLOAD_SIZE + 1);
let url = spawn_responder(vec![body]);
let fetcher = Fetcher::new(&url, &cache_dir, false).unwrap();
let result = fetcher.validated_get(&url);
assert!(result.is_err());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn validated_get_network_error_retries() {
let cache_dir = tmp_cache("get_neterr");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let url = format!("http://{addr}");
let fetcher = Fetcher::new(&url, &cache_dir, false).unwrap();
let result = fetcher.validated_get(&url);
assert!(result.is_err());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn download_content_and_decompress_writes_decompressed() {
let cache_dir = tmp_cache("dec_dl");
let payload = zlib_compress(b"decompressed payload");
let body = http_response(&payload, "200 OK");
let url = spawn_responder(vec![body]);
let fetcher = Fetcher::new(&url, &cache_dir, false).unwrap();
let cached = format!("{cache_dir}/out");
fetcher.download_content_and_decompress(&cached, &url).unwrap();
assert_eq!(fs::read_to_string(&cached).unwrap(), "decompressed payload");
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn retrieve_file_from_source_downloads_and_caches() {
let cache_dir = tmp_cache("from_src");
let payload = zlib_compress(b"from source body");
let body = http_response(&payload, "200 OK");
let url = spawn_responder(vec![body]);
let fetcher = Fetcher::new(&url, &cache_dir, true).unwrap();
let path = fetcher.retrieve_file_from_source("data/myfile").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "from source body");
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn retrieve_file_network_success_caches_payload() {
let cache_dir = tmp_cache("net_ok");
let payload = zlib_compress(b"network ok");
let body = http_response(&payload, "200 OK");
let url = spawn_responder(vec![body]);
let fetcher = Fetcher::new(&url, &cache_dir, true).unwrap();
let path = fetcher.retrieve_file("data/aa/file").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "network ok");
assert!(!fetcher.is_offline());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn retrieve_file_network_no_cache_propagates_error() {
let cache_dir = tmp_cache("no_cache");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let url = format!("http://{addr}");
let fetcher = Fetcher::new(&url, &cache_dir, true).unwrap();
let result = fetcher.retrieve_file("nonexistent_uncached");
assert!(result.is_err());
assert_eq!(fetcher.io_error_count(), 1);
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn retrieve_file_verified_success_and_mismatch() {
let cache_dir = tmp_cache("verified");
let fetcher = Fetcher::new("http://example.com", &cache_dir, true).unwrap();
let payload = b"verified payload";
let mut hasher = Sha1::new();
hasher.update(payload);
let hash = hex::encode(hasher.finalize());
fs::write(format!("{cache_dir}/file_v"), payload).unwrap();
let path = fetcher.retrieve_file_verified("file_v", &hash).unwrap();
assert!(path.contains("file_v"));
let bad =
fetcher.retrieve_file_verified("file_v", "0000000000000000000000000000000000000000");
assert!(bad.is_err());
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn sort_mirrors_by_geo_reorders() {
let cache_dir = tmp_cache("sort_mirrors");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = b"2,1,3\n";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.write_all(body);
}
});
let geo_url = format!("http://{addr}");
let mut fetcher = Fetcher::with_mirrors(
&[geo_url.as_str(), "http://b.example.com", "http://c.example.com"],
&cache_dir,
false,
)
.unwrap();
fetcher.sort_mirrors_by_geo("repo");
assert_eq!(fetcher.mirrors.len(), 2);
fs::remove_dir_all(&cache_dir).ok();
}
#[test]
fn with_mirrors_local_dir_mirror() {
let cache_dir = tmp_cache("mirrors_local");
let mirror_dir = tmp_cache("mirror_repo");
let fetcher =
Fetcher::with_mirrors(&["http://a.com", &mirror_dir], &cache_dir, false).unwrap();
assert!(fetcher.mirrors[0].starts_with("file://"));
fs::remove_dir_all(&cache_dir).ok();
fs::remove_dir_all(&mirror_dir).ok();
}
}