#![warn(clippy::all)]
#![warn(clippy::pedantic)]
#![warn(clippy::nursery)]
#![warn(missing_docs)]
#![allow(clippy::missing_errors_doc)]
#![doc = include_str!("../README.md")]
use anyinput::anyinput;
pub use ctor::ctor;
use directories::ProjectDirs;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
env,
ffi::OsString,
fs::{self, read_dir, File},
io::{self, ErrorKind},
path::{Path, PathBuf},
sync::{Mutex, MutexGuard},
thread,
time::Duration,
};
use tempfile::NamedTempFile;
use thiserror::Error;
pub struct FetchData {
mutex: Mutex<Result<Internals, Box<FetchDataError>>>,
}
impl FetchData {
#[anyinput]
pub fn new(
registry_contents: AnyString,
url_root: AnyString,
env_key: AnyString,
qualifier: AnyString,
organization: AnyString,
application: AnyString,
) -> Self {
Self {
mutex: Mutex::new(Internals::new(
registry_contents,
url_root,
env_key,
qualifier,
organization,
application,
)),
}
}
fn lock(&self) -> MutexGuard<'_, Result<Internals, Box<FetchDataError>>> {
match self.mutex.lock() {
Ok(lock) => lock,
Err(err) => err.into_inner(),
}
}
#[anyinput]
pub fn fetch_file(&self, path: AnyPath) -> Result<PathBuf, Box<FetchDataError>> {
let path_list = vec![path.to_path_buf()];
let vec = self.fetch_files(path_list)?;
Ok(vec[0].clone())
}
#[anyinput]
#[allow(clippy::significant_drop_tightening)]
pub fn fetch_files(
&self,
path_list: AnyIter<AnyPath>,
) -> Result<Vec<PathBuf>, Box<FetchDataError>> {
let lock = self.lock();
let lock_ref = lock.as_ref().map_err(|e| &**e);
let internals = Self::internals(lock_ref)?;
let hash_registry = &internals.hash_registry;
let cache_dir = &internals.cache_dir;
let url_root = &internals.url_root;
let mut local_list: Vec<PathBuf> = Vec::new();
for path in path_list {
let path = path.as_ref();
let path_as_string = path.to_str().ok_or_else(|| {
Box::new(FetchDataSpecificError::UnknownOrBadFile("???".to_string()).into())
})?;
let Some(hash) = hash_registry.get(path) else {
return Err(Box::new(
FetchDataSpecificError::UnknownOrBadFile(path_as_string.to_string()).into(),
));
};
let local_path = cache_dir.join(path);
let url = format!("{url_root}{path_as_string}");
fetch(url, hash, &local_path)?;
local_list.push(local_path);
}
Ok(local_list)
}
fn internals<'a>(
lock_ref: Result<&'a Internals, &FetchDataError>,
) -> Result<&'a Internals, Box<FetchDataError>> {
match lock_ref {
Ok(internals) => Ok(internals),
Err(e) => Err(Box::new(
FetchDataSpecificError::FetchDataNewFailed(e.to_string()).into(),
)),
}
}
#[anyinput]
#[allow(clippy::significant_drop_tightening)]
pub fn gen_registry_contents(
&self,
path_list: AnyIter<AnyPath>,
) -> Result<String, Box<FetchDataError>> {
let lock = self.lock();
let lock_ref = lock.as_ref().map_err(|e| &**e);
let internals = Self::internals(lock_ref)?;
let cache_dir = &internals.cache_dir;
let url_root = &internals.url_root;
let mut s = String::new();
for path in path_list {
let path = path.as_ref();
let Some(path_as_string) = path.to_str() else {
return Err(Box::new(
FetchDataSpecificError::UnknownOrBadFile("???".to_string()).into(),
));
};
let local_path = cache_dir.join(path);
let url = format!("{url_root}{path_as_string}");
download(url, &local_path)?;
let hash = hash_file(&local_path)?;
s.push_str(path_as_string);
s.push(' ');
s.push_str(&hash);
s.push('\n');
}
Ok(s)
}
#[allow(clippy::significant_drop_tightening)]
pub fn cache_dir(&self) -> Result<PathBuf, Box<FetchDataError>> {
let lock = self.lock();
let lock_ref = lock.as_ref().map_err(|e| &**e);
let internals = Self::internals(lock_ref)?;
let cache_dir = &internals.cache_dir;
Ok(cache_dir.to_owned())
}
}
#[derive(Error, Debug)]
pub enum FetchDataError {
#[allow(missing_docs)]
#[error(transparent)]
FetchDataError(#[from] FetchDataSpecificError),
#[allow(missing_docs)]
#[error(transparent)]
IOError(#[from] io::Error),
#[allow(missing_docs)]
#[error(transparent)]
UreqError(#[from] ureq::Error),
}
#[derive(Error, Debug, Clone)]
pub enum FetchDataSpecificError {
#[allow(missing_docs)]
#[error("Unknown or bad file '{0}'")]
UnknownOrBadFile(String),
#[allow(missing_docs)]
#[error("The registry of files is invalid")]
RegistryProblem(),
#[allow(missing_docs)]
#[error("FetchData new failed with error: {0}")]
FetchDataNewFailed(String),
#[allow(missing_docs)]
#[error("Downloaded file not seen: {0}")]
DownloadedFileNotSeen(String),
#[allow(missing_docs)]
#[error("Downloaded file has wrong hash: {0},expected: {1}, actual: {2}")]
DownloadedFileWrongHash(String, String, String),
#[allow(missing_docs)]
#[error("Cannot create cache directory")]
CannotCreateCacheDir(),
}
#[anyinput]
pub fn fetch(url: AnyString, hash: AnyString, path: AnyPath) -> Result<(), Box<FetchDataError>> {
if !path.exists() {
download(url, path)?;
}
let mut actual_hash = hash_file(path)?;
if !actual_hash.eq(hash) {
download(url, path)?;
actual_hash = hash_file(path)?;
}
if !actual_hash.eq(hash) {
return Err(Box::new(
FetchDataSpecificError::DownloadedFileWrongHash(
path.display().to_string(),
hash.to_string(),
actual_hash,
)
.into(),
));
}
Ok(())
}
#[anyinput]
pub fn hash_download(url: AnyString, path: AnyPath) -> Result<String, Box<FetchDataError>> {
download(url, path)?;
hash_file(path)
}
#[anyinput]
pub fn hash_file(path: AnyPath) -> Result<String, Box<FetchDataError>> {
let mut sha256 = Sha256::new();
let mut file = File::open(path).map_err(|e| Box::new(e.into()))?;
io::copy(&mut file, &mut sha256).map_err(|e| Box::new(e.into()))?;
let hash_bytes = sha256.finalize();
let hex_hash = base16ct::lower::encode_string(&hash_bytes);
Ok(hex_hash)
}
#[anyinput]
pub fn download(url: AnyString, path: AnyPath) -> Result<(), Box<FetchDataError>> {
let mut attempt = 1;
loop {
match download_once(url, path) {
Ok(()) => break,
Err(error) if attempt < DOWNLOAD_ATTEMPTS && is_transient(&error) => {
thread::sleep(RETRY_BASE_DELAY * (1 << (attempt - 1)));
attempt += 1;
}
Err(error) => return Err(error),
}
}
if !path.exists() {
return Err(Box::new(
FetchDataSpecificError::DownloadedFileNotSeen(path.display().to_string()).into(),
));
}
Ok(())
}
const DOWNLOAD_ATTEMPTS: u32 = 4;
#[cfg(not(test))]
const RETRY_BASE_DELAY: Duration = Duration::from_secs(1);
#[cfg(test)]
const RETRY_BASE_DELAY: Duration = Duration::from_millis(10);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const READ_TIMEOUT: Duration = Duration::from_secs(60);
fn download_once(url: &str, path: &Path) -> Result<(), Box<FetchDataError>> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(CONNECT_TIMEOUT)
.timeout_read(READ_TIMEOUT)
.build();
let response = agent
.get(url)
.call()
.map_err(|error| Box::new(error.into()))?;
let directory = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let mut temp_file = NamedTempFile::new_in(directory).map_err(|error| Box::new(error.into()))?;
io::copy(&mut response.into_reader(), &mut temp_file)
.map_err(|error| Box::new(error.into()))?;
temp_file
.as_file()
.sync_all()
.map_err(|error| Box::new(error.into()))?;
temp_file
.persist(path)
.map_err(|persist_error| Box::new(persist_error.error.into()))?;
Ok(())
}
fn is_transient(error: &FetchDataError) -> bool {
match error {
FetchDataError::UreqError(ureq_error) => match ureq_error {
ureq::Error::Status(code, _) => *code == 429 || *code >= 500,
ureq::Error::Transport(_) => true,
},
FetchDataError::IOError(io_error) => matches!(
io_error.kind(),
ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::BrokenPipe
| ErrorKind::TimedOut
| ErrorKind::UnexpectedEof
| ErrorKind::Interrupted
| ErrorKind::WouldBlock
),
FetchDataError::FetchDataError(_) => false,
}
}
fn hash_registry(registry_contents: &str) -> Result<HashMap<PathBuf, String>, Box<FetchDataError>> {
let mut hash_map = HashMap::new();
for line in registry_contents.lines() {
let mut parts = line.split_whitespace();
let url = if let Some(url) = parts.next() {
if url.is_empty() {
return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
}
PathBuf::from(url)
} else {
return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
};
let hash = if let Some(hash) = parts.next() {
hash.to_string()
} else {
return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
};
if hash.is_empty() || parts.next().is_some() {
return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
}
hash_map.insert(url, hash.clone());
}
Ok(hash_map)
}
#[anyinput]
pub fn dir_to_file_list(path: AnyPath) -> Result<Vec<OsString>, Box<FetchDataError>> {
let file_list = read_dir(path)
.map_err(|error| Box::new(error.into()))?
.map(|res| res.map(|e| e.file_name()))
.collect::<Result<Vec<_>, io::Error>>()
.map_err(|error| Box::new(error.into()))?;
Ok(file_list)
}
struct Internals {
cache_dir: PathBuf,
hash_registry: HashMap<PathBuf, String>,
url_root: String,
}
impl Internals {
fn new(
registry_contents: &str,
url_root: &str,
env_key: &str,
qualifier: &str,
organization: &str,
application: &str,
) -> Result<Self, Box<FetchDataError>> {
let cache_dir = Self::cache_dir(env_key, qualifier, organization, application)?;
let hash_registry = hash_registry(registry_contents)?;
Ok(Self {
cache_dir,
hash_registry,
url_root: url_root.to_string(),
})
}
fn cache_dir(
env_key: &str,
qualifier: &str,
organization: &str,
application: &str,
) -> Result<PathBuf, Box<FetchDataError>> {
let cache_dir = if let Ok(cache_dir) = env::var(env_key) {
PathBuf::from(cache_dir)
} else if let Some(proj_dirs) = ProjectDirs::from(qualifier, organization, application) {
proj_dirs.cache_dir().to_owned()
} else {
return Err(Box::new(
FetchDataSpecificError::CannotCreateCacheDir().into(),
));
};
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir).map_err(|error| Box::new(error.into()))?;
}
Ok(cache_dir)
}
}
#[ctor]
static STATIC_FETCH_DATA: FetchData = FetchData::new(
include_str!("../registry.txt"),
"https://raw.githubusercontent.com/CarlKCarlK/fetch-data/main/tests/data/",
"BAR_APP_DATA_DIR",
"com",
"Foo Corp",
"Bar App",
);
#[anyinput]
pub fn sample_file(path: AnyPath) -> Result<PathBuf, Box<FetchDataError>> {
STATIC_FETCH_DATA.fetch_file(path)
}
#[cfg(test)]
mod download_tests {
use super::{download, fetch, hash_file, DOWNLOAD_ATTEMPTS};
use std::{
fs,
io::{self, BufRead, BufReader, Write},
net::TcpListener,
path::Path,
thread::{self, JoinHandle},
};
use temp_testdir::TempDir;
enum Reply {
Status(u16),
Body(&'static [u8]),
Truncated(usize, &'static [u8]),
}
fn serve(replies: Vec<Reply>) -> io::Result<(String, JoinHandle<io::Result<usize>>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let url = format!("http://{}/small.fam", listener.local_addr()?);
let server = thread::spawn(move || {
let mut served = 0;
for reply in replies {
let (mut stream, _) = listener.accept()?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
while reader.read_line(&mut line)? > 2 {
line.clear();
}
let response = match reply {
Reply::Status(code) => format!(
"HTTP/1.1 {code} Simulated\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes(),
Reply::Body(body) => [
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.as_bytes(),
body,
]
.concat(),
Reply::Truncated(len, body) => [
format!("HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n")
.as_bytes(),
body,
]
.concat(),
};
stream.write_all(&response)?;
served += 1;
}
Ok(served)
});
Ok((url, server))
}
fn served(server: JoinHandle<io::Result<usize>>) -> io::Result<usize> {
server.join().expect("server thread panicked")
}
fn file_names(dir: &Path) -> io::Result<Vec<String>> {
let mut names = fs::read_dir(dir)?
.map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned()))
.collect::<io::Result<Vec<String>>>()?;
names.sort();
Ok(names)
}
#[test]
fn retries_server_errors() -> anyhow::Result<()> {
let (url, server) = serve(vec![
Reply::Status(503),
Reply::Status(502),
Reply::Body(b"hello"),
])?;
let temp_dir = TempDir::default();
let path = temp_dir.join("small.fam");
download(&url, &path)?;
assert_eq!(fs::read(&path)?, b"hello");
assert_eq!(served(server)?, 3);
Ok(())
}
#[test]
fn does_not_retry_not_found() -> anyhow::Result<()> {
let (url, server) = serve(vec![Reply::Status(404)])?;
let temp_dir = TempDir::default();
let path = temp_dir.join("small.fam");
assert!(download(&url, &path).is_err());
assert_eq!(served(server)?, 1);
assert!(!path.exists());
Ok(())
}
#[test]
fn truncated_body_is_retried_without_leaving_partial_file() -> anyhow::Result<()> {
let (url, server) = serve(vec![
Reply::Truncated(10, b"0123"),
Reply::Body(b"complete"),
])?;
let temp_dir = TempDir::default();
let path = temp_dir.join("small.fam");
fs::write(&path, b"old")?;
download(&url, &path)?;
assert_eq!(fs::read(&path)?, b"complete");
assert_eq!(served(server)?, 2);
assert_eq!(file_names(&temp_dir)?, ["small.fam"]);
Ok(())
}
#[test]
fn gives_up_and_keeps_previous_file() -> anyhow::Result<()> {
let replies = (0..DOWNLOAD_ATTEMPTS).map(|_| Reply::Status(503)).collect();
let (url, server) = serve(replies)?;
let temp_dir = TempDir::default();
let path = temp_dir.join("small.fam");
fs::write(&path, b"old")?;
assert!(download(&url, &path).is_err());
assert_eq!(served(server)?, usize::try_from(DOWNLOAD_ATTEMPTS)?);
assert_eq!(fs::read(&path)?, b"old");
assert_eq!(file_names(&temp_dir)?, ["small.fam"]);
Ok(())
}
#[test]
fn fetch_replaces_corrupt_local_file() -> anyhow::Result<()> {
let (url, server) = serve(vec![Reply::Body(b"good")])?;
let temp_dir = TempDir::default();
let good = temp_dir.join("good");
fs::write(&good, b"good")?;
let hash = hash_file(&good)?;
let path = temp_dir.join("small.fam");
fs::write(&path, b"partial from an old interrupted download")?;
fetch(&url, &hash, &path)?;
assert_eq!(fs::read(&path)?, b"good");
assert_eq!(served(server)?, 1);
Ok(())
}
}