1#![warn(clippy::all)]
2#![warn(clippy::pedantic)]
3#![warn(clippy::nursery)]
4#![warn(missing_docs)]
5#![allow(clippy::missing_errors_doc)]
6#![doc = include_str!("../README.md")]
7
8use anyinput::anyinput;
9pub use ctor::ctor;
13use directories::ProjectDirs;
14
15use sha2::{Digest, Sha256};
16use std::{
17 collections::HashMap,
18 env,
19 ffi::OsString,
20 fs::{self, read_dir, File},
21 io::{self, ErrorKind},
22 path::{Path, PathBuf},
23 sync::{Mutex, MutexGuard},
24 thread,
25 time::Duration,
26};
27use tempfile::NamedTempFile;
28use thiserror::Error;
29
30pub struct FetchData {
37 mutex: Mutex<Result<Internals, Box<FetchDataError>>>,
38}
39
40impl FetchData {
41 #[anyinput]
86 pub fn new(
87 registry_contents: AnyString,
88 url_root: AnyString,
89 env_key: AnyString,
90 qualifier: AnyString,
91 organization: AnyString,
92 application: AnyString,
93 ) -> Self {
94 Self {
95 mutex: Mutex::new(Internals::new(
96 registry_contents,
97 url_root,
98 env_key,
99 qualifier,
100 organization,
101 application,
102 )),
103 }
104 }
105
106 fn lock(&self) -> MutexGuard<'_, Result<Internals, Box<FetchDataError>>> {
107 match self.mutex.lock() {
108 Ok(lock) => lock,
109 Err(err) => err.into_inner(),
110 }
111 }
112
113 #[anyinput]
138 pub fn fetch_file(&self, path: AnyPath) -> Result<PathBuf, Box<FetchDataError>> {
139 let path_list = vec![path.to_path_buf()];
140 let vec = self.fetch_files(path_list)?;
141 Ok(vec[0].clone())
142 }
143
144 #[anyinput]
170 #[allow(clippy::significant_drop_tightening)]
171 pub fn fetch_files(
172 &self,
173 path_list: AnyIter<AnyPath>,
174 ) -> Result<Vec<PathBuf>, Box<FetchDataError>> {
175 let lock = self.lock();
176 let lock_ref = lock.as_ref().map_err(|e| &**e);
178 let internals = Self::internals(lock_ref)?;
179 let hash_registry = &internals.hash_registry;
180 let cache_dir = &internals.cache_dir;
181 let url_root = &internals.url_root;
182
183 let mut local_list: Vec<PathBuf> = Vec::new();
184 for path in path_list {
185 let path = path.as_ref();
186
187 let path_as_string = path.to_str().ok_or_else(|| {
188 Box::new(FetchDataSpecificError::UnknownOrBadFile("???".to_string()).into())
189 })?;
190
191 let Some(hash) = hash_registry.get(path) else {
192 return Err(Box::new(
193 FetchDataSpecificError::UnknownOrBadFile(path_as_string.to_string()).into(),
194 ));
195 };
196
197 let local_path = cache_dir.join(path);
198 let url = format!("{url_root}{path_as_string}");
199 fetch(url, hash, &local_path)?;
200 local_list.push(local_path);
201 }
202
203 Ok(local_list)
204 }
205
206 fn internals<'a>(
207 lock_ref: Result<&'a Internals, &FetchDataError>,
208 ) -> Result<&'a Internals, Box<FetchDataError>> {
209 match lock_ref {
210 Ok(internals) => Ok(internals),
211 Err(e) => Err(Box::new(
212 FetchDataSpecificError::FetchDataNewFailed(e.to_string()).into(),
213 )),
214 }
215 }
216 #[anyinput]
250 #[allow(clippy::significant_drop_tightening)]
251 pub fn gen_registry_contents(
252 &self,
253 path_list: AnyIter<AnyPath>,
254 ) -> Result<String, Box<FetchDataError>> {
255 let lock = self.lock();
256 let lock_ref = lock.as_ref().map_err(|e| &**e);
258 let internals = Self::internals(lock_ref)?;
259 let cache_dir = &internals.cache_dir;
260 let url_root = &internals.url_root;
261
262 let mut s = String::new();
263 for path in path_list {
264 let path = path.as_ref();
265
266 let Some(path_as_string) = path.to_str() else {
267 return Err(Box::new(
268 FetchDataSpecificError::UnknownOrBadFile("???".to_string()).into(),
269 ));
270 };
271
272 let local_path = cache_dir.join(path);
273 let url = format!("{url_root}{path_as_string}");
274 download(url, &local_path)?;
275 let hash = hash_file(&local_path)?;
276 s.push_str(path_as_string);
277 s.push(' ');
278 s.push_str(&hash);
279 s.push('\n');
280 }
281
282 Ok(s)
283 }
284
285 #[allow(clippy::significant_drop_tightening)]
287 pub fn cache_dir(&self) -> Result<PathBuf, Box<FetchDataError>> {
288 let lock = self.lock();
289 let lock_ref = lock.as_ref().map_err(|e| &**e);
291 let internals = Self::internals(lock_ref)?;
292 let cache_dir = &internals.cache_dir;
293 Ok(cache_dir.to_owned())
294 }
295}
296
297#[derive(Error, Debug)]
300pub enum FetchDataError {
301 #[allow(missing_docs)]
302 #[error(transparent)]
303 FetchDataError(#[from] FetchDataSpecificError),
304
305 #[allow(missing_docs)]
306 #[error(transparent)]
307 IOError(#[from] io::Error),
308
309 #[allow(missing_docs)]
310 #[error(transparent)]
311 UreqError(#[from] ureq::Error),
312}
313#[derive(Error, Debug, Clone)]
315pub enum FetchDataSpecificError {
316 #[allow(missing_docs)]
317 #[error("Unknown or bad file '{0}'")]
318 UnknownOrBadFile(String),
319
320 #[allow(missing_docs)]
321 #[error("The registry of files is invalid")]
322 RegistryProblem(),
323
324 #[allow(missing_docs)]
325 #[error("FetchData new failed with error: {0}")]
326 FetchDataNewFailed(String),
327
328 #[allow(missing_docs)]
329 #[error("Downloaded file not seen: {0}")]
330 DownloadedFileNotSeen(String),
331
332 #[allow(missing_docs)]
333 #[error("Downloaded file has wrong hash: {0},expected: {1}, actual: {2}")]
334 DownloadedFileWrongHash(String, String, String),
335
336 #[allow(missing_docs)]
337 #[error("Cannot create cache directory")]
338 CannotCreateCacheDir(),
339}
340
341#[anyinput]
368pub fn fetch(url: AnyString, hash: AnyString, path: AnyPath) -> Result<(), Box<FetchDataError>> {
369 if !path.exists() {
370 download(url, path)?;
371 }
372 let mut actual_hash = hash_file(path)?;
373 if !actual_hash.eq(hash) {
374 download(url, path)?;
376 actual_hash = hash_file(path)?;
377 }
378 if !actual_hash.eq(hash) {
379 return Err(Box::new(
380 FetchDataSpecificError::DownloadedFileWrongHash(
381 path.display().to_string(),
382 hash.to_string(),
383 actual_hash,
384 )
385 .into(),
386 ));
387 }
388 Ok(())
389}
390
391#[anyinput]
411pub fn hash_download(url: AnyString, path: AnyPath) -> Result<String, Box<FetchDataError>> {
412 download(url, path)?;
413 hash_file(path)
414}
415
416#[anyinput]
436pub fn hash_file(path: AnyPath) -> Result<String, Box<FetchDataError>> {
437 let mut sha256 = Sha256::new();
438 let mut file = File::open(path).map_err(|e| Box::new(e.into()))?;
439
440 io::copy(&mut file, &mut sha256).map_err(|e| Box::new(e.into()))?;
441 let hash_bytes = sha256.finalize();
442
443 let hex_hash = base16ct::lower::encode_string(&hash_bytes);
444 Ok(hex_hash)
445}
446
447#[anyinput]
472pub fn download(url: AnyString, path: AnyPath) -> Result<(), Box<FetchDataError>> {
473 let mut attempt = 1;
474 loop {
475 match download_once(url, path) {
476 Ok(()) => break,
477 Err(error) if attempt < DOWNLOAD_ATTEMPTS && is_transient(&error) => {
478 thread::sleep(RETRY_BASE_DELAY * (1 << (attempt - 1)));
479 attempt += 1;
480 }
481 Err(error) => return Err(error),
482 }
483 }
484 if !path.exists() {
485 return Err(Box::new(
486 FetchDataSpecificError::DownloadedFileNotSeen(path.display().to_string()).into(),
487 ));
488 }
489 Ok(())
490}
491
492const DOWNLOAD_ATTEMPTS: u32 = 4;
494#[cfg(not(test))]
496const RETRY_BASE_DELAY: Duration = Duration::from_secs(1);
497#[cfg(test)]
498const RETRY_BASE_DELAY: Duration = Duration::from_millis(10);
499const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
500const READ_TIMEOUT: Duration = Duration::from_secs(60);
502
503fn download_once(url: &str, path: &Path) -> Result<(), Box<FetchDataError>> {
504 let agent = ureq::AgentBuilder::new()
505 .timeout_connect(CONNECT_TIMEOUT)
506 .timeout_read(READ_TIMEOUT)
507 .build();
508 let response = agent
509 .get(url)
510 .call()
511 .map_err(|error| Box::new(error.into()))?;
512
513 let directory = match path.parent() {
514 Some(parent) if !parent.as_os_str().is_empty() => parent,
515 _ => Path::new("."),
516 };
517 let mut temp_file = NamedTempFile::new_in(directory).map_err(|error| Box::new(error.into()))?;
520 io::copy(&mut response.into_reader(), &mut temp_file)
521 .map_err(|error| Box::new(error.into()))?;
522 temp_file
523 .as_file()
524 .sync_all()
525 .map_err(|error| Box::new(error.into()))?;
526 temp_file
527 .persist(path)
528 .map_err(|persist_error| Box::new(persist_error.error.into()))?;
529 Ok(())
530}
531
532fn is_transient(error: &FetchDataError) -> bool {
533 match error {
534 FetchDataError::UreqError(ureq_error) => match ureq_error {
535 ureq::Error::Status(code, _) => *code == 429 || *code >= 500,
536 ureq::Error::Transport(_) => true,
537 },
538 FetchDataError::IOError(io_error) => matches!(
540 io_error.kind(),
541 ErrorKind::ConnectionReset
542 | ErrorKind::ConnectionAborted
543 | ErrorKind::BrokenPipe
544 | ErrorKind::TimedOut
545 | ErrorKind::UnexpectedEof
546 | ErrorKind::Interrupted
547 | ErrorKind::WouldBlock
548 ),
549 FetchDataError::FetchDataError(_) => false,
550 }
551}
552
553fn hash_registry(registry_contents: &str) -> Result<HashMap<PathBuf, String>, Box<FetchDataError>> {
554 let mut hash_map = HashMap::new();
555 for line in registry_contents.lines() {
556 let mut parts = line.split_whitespace();
557
558 let url = if let Some(url) = parts.next() {
559 if url.is_empty() {
560 return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
561 }
562 PathBuf::from(url)
563 } else {
564 return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
565 };
566 let hash = if let Some(hash) = parts.next() {
567 hash.to_string()
568 } else {
569 return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
570 };
571 if hash.is_empty() || parts.next().is_some() {
572 return Err(Box::new(FetchDataSpecificError::RegistryProblem().into()));
573 }
574
575 hash_map.insert(url, hash.clone());
576 }
577 Ok(hash_map)
578}
579
580#[anyinput]
604pub fn dir_to_file_list(path: AnyPath) -> Result<Vec<OsString>, Box<FetchDataError>> {
605 let file_list = read_dir(path)
606 .map_err(|error| Box::new(error.into()))?
607 .map(|res| res.map(|e| e.file_name()))
608 .collect::<Result<Vec<_>, io::Error>>()
609 .map_err(|error| Box::new(error.into()))?;
610 Ok(file_list)
611}
612struct Internals {
613 cache_dir: PathBuf,
614 hash_registry: HashMap<PathBuf, String>,
615 url_root: String,
616}
617
618impl Internals {
619 fn new(
620 registry_contents: &str,
621 url_root: &str,
622 env_key: &str,
623 qualifier: &str,
624 organization: &str,
625 application: &str,
626 ) -> Result<Self, Box<FetchDataError>> {
627 let cache_dir = Self::cache_dir(env_key, qualifier, organization, application)?;
628 let hash_registry = hash_registry(registry_contents)?;
629
630 Ok(Self {
631 cache_dir,
632 hash_registry,
633 url_root: url_root.to_string(),
634 })
635 }
636
637 fn cache_dir(
638 env_key: &str,
639 qualifier: &str,
640 organization: &str,
641 application: &str,
642 ) -> Result<PathBuf, Box<FetchDataError>> {
643 let cache_dir = if let Ok(cache_dir) = env::var(env_key) {
644 PathBuf::from(cache_dir)
645 } else if let Some(proj_dirs) = ProjectDirs::from(qualifier, organization, application) {
646 proj_dirs.cache_dir().to_owned()
647 } else {
648 return Err(Box::new(
649 FetchDataSpecificError::CannotCreateCacheDir().into(),
650 ));
651 };
652 if !cache_dir.exists() {
653 fs::create_dir_all(&cache_dir).map_err(|error| Box::new(error.into()))?;
654 }
655 Ok(cache_dir)
656 }
657}
658
659#[ctor]
660static STATIC_FETCH_DATA: FetchData = FetchData::new(
661 include_str!("../registry.txt"),
662 "https://raw.githubusercontent.com/CarlKCarlK/fetch-data/main/tests/data/",
663 "BAR_APP_DATA_DIR",
664 "com",
665 "Foo Corp",
666 "Bar App",
667);
668
669#[anyinput]
672pub fn sample_file(path: AnyPath) -> Result<PathBuf, Box<FetchDataError>> {
673 STATIC_FETCH_DATA.fetch_file(path)
674}
675
676#[cfg(test)]
677mod download_tests {
678 use super::{download, fetch, hash_file, DOWNLOAD_ATTEMPTS};
679 use std::{
680 fs,
681 io::{self, BufRead, BufReader, Write},
682 net::TcpListener,
683 path::Path,
684 thread::{self, JoinHandle},
685 };
686 use temp_testdir::TempDir;
687
688 enum Reply {
689 Status(u16),
690 Body(&'static [u8]),
691 Truncated(usize, &'static [u8]),
693 }
694
695 fn serve(replies: Vec<Reply>) -> io::Result<(String, JoinHandle<io::Result<usize>>)> {
698 let listener = TcpListener::bind("127.0.0.1:0")?;
699 let url = format!("http://{}/small.fam", listener.local_addr()?);
700 let server = thread::spawn(move || {
701 let mut served = 0;
702 for reply in replies {
703 let (mut stream, _) = listener.accept()?;
704 let mut reader = BufReader::new(stream.try_clone()?);
705 let mut line = String::new();
706 while reader.read_line(&mut line)? > 2 {
707 line.clear();
708 }
709 let response = match reply {
710 Reply::Status(code) => format!(
711 "HTTP/1.1 {code} Simulated\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
712 )
713 .into_bytes(),
714 Reply::Body(body) => [
715 format!(
716 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
717 body.len()
718 )
719 .as_bytes(),
720 body,
721 ]
722 .concat(),
723 Reply::Truncated(len, body) => [
724 format!("HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n")
725 .as_bytes(),
726 body,
727 ]
728 .concat(),
729 };
730 stream.write_all(&response)?;
731 served += 1;
732 }
733 Ok(served)
734 });
735 Ok((url, server))
736 }
737
738 fn served(server: JoinHandle<io::Result<usize>>) -> io::Result<usize> {
739 server.join().expect("server thread panicked")
740 }
741
742 fn file_names(dir: &Path) -> io::Result<Vec<String>> {
743 let mut names = fs::read_dir(dir)?
744 .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned()))
745 .collect::<io::Result<Vec<String>>>()?;
746 names.sort();
747 Ok(names)
748 }
749
750 #[test]
751 fn retries_server_errors() -> anyhow::Result<()> {
752 let (url, server) = serve(vec![
753 Reply::Status(503),
754 Reply::Status(502),
755 Reply::Body(b"hello"),
756 ])?;
757 let temp_dir = TempDir::default();
758 let path = temp_dir.join("small.fam");
759 download(&url, &path)?;
760 assert_eq!(fs::read(&path)?, b"hello");
761 assert_eq!(served(server)?, 3);
762 Ok(())
763 }
764
765 #[test]
766 fn does_not_retry_not_found() -> anyhow::Result<()> {
767 let (url, server) = serve(vec![Reply::Status(404)])?;
768 let temp_dir = TempDir::default();
769 let path = temp_dir.join("small.fam");
770 assert!(download(&url, &path).is_err());
771 assert_eq!(served(server)?, 1);
772 assert!(!path.exists());
773 Ok(())
774 }
775
776 #[test]
777 fn truncated_body_is_retried_without_leaving_partial_file() -> anyhow::Result<()> {
778 let (url, server) = serve(vec![
779 Reply::Truncated(10, b"0123"),
780 Reply::Body(b"complete"),
781 ])?;
782 let temp_dir = TempDir::default();
783 let path = temp_dir.join("small.fam");
784 fs::write(&path, b"old")?;
785 download(&url, &path)?;
786 assert_eq!(fs::read(&path)?, b"complete");
787 assert_eq!(served(server)?, 2);
788 assert_eq!(file_names(&temp_dir)?, ["small.fam"]);
789 Ok(())
790 }
791
792 #[test]
793 fn gives_up_and_keeps_previous_file() -> anyhow::Result<()> {
794 let replies = (0..DOWNLOAD_ATTEMPTS).map(|_| Reply::Status(503)).collect();
795 let (url, server) = serve(replies)?;
796 let temp_dir = TempDir::default();
797 let path = temp_dir.join("small.fam");
798 fs::write(&path, b"old")?;
799 assert!(download(&url, &path).is_err());
800 assert_eq!(served(server)?, usize::try_from(DOWNLOAD_ATTEMPTS)?);
801 assert_eq!(fs::read(&path)?, b"old");
802 assert_eq!(file_names(&temp_dir)?, ["small.fam"]);
803 Ok(())
804 }
805
806 #[test]
807 fn fetch_replaces_corrupt_local_file() -> anyhow::Result<()> {
808 let (url, server) = serve(vec![Reply::Body(b"good")])?;
809 let temp_dir = TempDir::default();
810 let good = temp_dir.join("good");
811 fs::write(&good, b"good")?;
812 let hash = hash_file(&good)?;
813 let path = temp_dir.join("small.fam");
814 fs::write(&path, b"partial from an old interrupted download")?;
815 fetch(&url, &hash, &path)?;
816 assert_eq!(fs::read(&path)?, b"good");
817 assert_eq!(served(server)?, 1);
818 Ok(())
819 }
820}