use super::download::{self, download_file_async};
use super::manifest::{self, FetchOutcome};
use crate::utils::datadir;
use std::path::PathBuf;
use std::thread::JoinHandle;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Manifest URL {url:?} must use https://")]
InsecureManifestUrl { url: String },
#[error("Manifest file name {name:?} is not a plain path component")]
InvalidManifestPath { name: String },
#[error(
"Data directory is read-only. Try setting SATKIT_DATA environment variable \
to a writeable directory and re-starting"
)]
DataDirReadOnly,
#[error("Background download thread panicked")]
ThreadPanic,
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Datadir(#[from] crate::utils::datadir::Error),
#[error(transparent)]
Download(#[from] download::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub fn download_static_files(
dir: &std::path::Path,
force: bool,
) -> Result<Vec<(String, FetchOutcome)>> {
let m = manifest::embedded();
let handles: Vec<(String, JoinHandle<download::Result<FetchOutcome>>)> = m
.default_files()
.map(|entry| {
let entry = entry.clone();
let dir = dir.to_path_buf();
let name = entry.name.clone();
(
name,
std::thread::spawn(move || manifest::fetch_static_file(&entry, &dir, force)),
)
})
.collect();
let mut out = Vec::with_capacity(handles.len());
for (name, jh) in handles {
let outcome = jh.join().map_err(|_| Error::ThreadPanic)??;
out.push((name, outcome));
}
Ok(out)
}
fn download_refresh_files(dir: &std::path::Path) -> Result<()> {
let m = manifest::embedded();
let handles: Vec<JoinHandle<download::Result<bool>>> = m
.refresh
.iter()
.map(|url| -> Result<_> {
if !url.starts_with("https://") {
return Err(Error::InsecureManifestUrl { url: url.clone() });
}
Ok(download_file_async(url.clone(), dir, true))
})
.collect::<Result<Vec<_>>>()?;
for jh in handles {
jh.join().map_err(|_| Error::ThreadPanic)??;
}
Ok(())
}
pub fn update_datafiles(dir: Option<PathBuf>, overwrite_if_exists: bool) -> Result<()> {
let downloaddir = match dir {
Some(d) => d,
None => datadir()?,
};
if !downloaddir.is_dir() {
std::fs::create_dir_all(&downloaddir)?;
}
if downloaddir.metadata()?.permissions().readonly() {
return Err(Error::DataDirReadOnly);
}
let m = manifest::embedded();
println!(
"Downloading data files ({}) to {}",
m.data_version,
downloaddir.to_string_lossy()
);
if let Some(mirror) = manifest::mirror_base() {
println!(" {} = {mirror} (tried first)", manifest::MIRROR_ENV);
}
for (name, outcome) in download_static_files(&downloaddir, overwrite_if_exists)? {
match outcome {
FetchOutcome::AlreadyPresent => println!(" {name}: present and verified"),
FetchOutcome::Downloaded { url } => println!(" {name}: downloaded from {url}"),
}
}
println!("Now downloading files that are regularly updated:");
println!(" Space Weather & Earth Orientation Parameters");
download_refresh_files(&downloaddir)?;
println!(" Solar Cycle Forecast");
if let Err(e) = crate::solar_cycle_forecast::update() {
eprintln!("Warning: could not download solar cycle forecast: {e}");
}
let sw_path = downloaddir.join("SW-All.csv");
if sw_path.is_file() {
if let Err(e) = crate::spaceweather::init_from_path(&sw_path) {
eprintln!("Warning: could not load downloaded space-weather file: {e}");
}
}
let eop_path = downloaddir.join("EOP-All.csv");
if eop_path.is_file() {
if let Err(e) = crate::earth_orientation_params::init_from_path(&eop_path) {
eprintln!("Warning: could not load downloaded EOP file: {e}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::manifest::{sha256_hex, ManifestEntry};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
struct TestServer {
base: String,
hits: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl TestServer {
fn start(files: HashMap<String, Vec<u8>>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let hits = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let files = Arc::new(Mutex::new(files));
let (h2, s2, f2) = (hits.clone(), stop.clone(), files.clone());
let thread = std::thread::spawn(move || {
while !s2.load(Ordering::Relaxed) {
match listener.accept() {
Ok((mut sock, _)) => {
h2.fetch_add(1, Ordering::Relaxed);
sock.set_nonblocking(false).unwrap();
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let path = req
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("/")
.trim_start_matches('/')
.to_string();
let body = f2.lock().unwrap().get(&path).cloned();
let resp = match body {
Some(b) => {
let mut r = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
b.len()
)
.into_bytes();
r.extend_from_slice(&b);
r
}
None => b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(),
};
let _ = sock.write_all(&resp);
let _ = sock.flush();
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(_) => break,
}
}
});
Self {
base: format!("http://127.0.0.1:{port}"),
hits,
stop,
thread: Some(thread),
}
}
fn url(&self, path: &str) -> String {
format!("{}/{path}", self.base)
}
fn hits(&self) -> usize {
self.hits.load(Ordering::Relaxed)
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn entry(name: &str, bytes: &[u8], urls: Vec<String>) -> ManifestEntry {
ManifestEntry {
name: name.into(),
size: bytes.len() as u64,
sha256: sha256_hex(bytes),
urls,
source: "test".into(),
license: String::new(),
tier: "core".into(),
default: true,
}
}
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("satkit_fetch_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn offline_mode_blocks_fetch_without_network_io() {
let _guard = crate::utils::manifest::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let bytes = b"offline test bytes".to_vec();
let server = TestServer::start(HashMap::from([("f.txt".to_string(), bytes.clone())]));
let e = entry("f.txt", &bytes, vec![server.url("f.txt")]);
let dir = tmpdir("offline");
download::set_offline(true);
let err = manifest::fetch_static_file(&e, &dir, false).unwrap_err();
download::set_offline(false);
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
download::clear_offline_override();
}
}
let _restore = Restore;
assert!(
matches!(&err, download::Error::Offline { name, urls, .. } if name == "f.txt" && urls.len() == 1),
"{err}"
);
assert!(err.to_string().contains(&server.url("f.txt")));
assert_eq!(server.hits(), 0, "offline mode must not open a connection");
assert!(!dir.join("f.txt").exists());
manifest::fetch_static_file(&e, &dir, false).unwrap();
assert_eq!(server.hits(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn offline_setter_overrides_environment() {
let _guard = crate::utils::manifest::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior_env = std::env::var_os(download::OFFLINE_ENV);
std::env::set_var(download::OFFLINE_ENV, "1");
download::set_offline(false);
assert!(!download::is_offline());
std::env::remove_var(download::OFFLINE_ENV);
download::set_offline(true);
assert!(download::is_offline());
download::set_offline(false);
assert!(!download::is_offline());
download::clear_offline_override();
assert!(!download::is_offline());
match prior_env {
Some(v) => std::env::set_var(download::OFFLINE_ENV, v),
None => std::env::remove_var(download::OFFLINE_ENV),
}
}
#[test]
fn fetch_success_is_verified_and_cached() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data = b"the quick brown fox".to_vec();
let srv = TestServer::start(HashMap::from([("good.bin".to_string(), data.clone())]));
let dir = tmpdir("ok");
let e = entry("good.bin", &data, vec![srv.url("good.bin")]);
let out = manifest::fetch_static_file(&e, &dir, false).unwrap();
assert_eq!(
out,
FetchOutcome::Downloaded {
url: srv.url("good.bin")
}
);
assert_eq!(std::fs::read(dir.join("good.bin")).unwrap(), data);
assert!(!dir.join("good.bin.part").exists());
assert_eq!(srv.hits(), 1);
let out = manifest::fetch_static_file(&e, &dir, false).unwrap();
assert_eq!(out, FetchOutcome::AlreadyPresent);
assert_eq!(srv.hits(), 1, "verified file must not be re-downloaded");
let out = manifest::fetch_static_file(&e, &dir, true).unwrap();
assert!(matches!(out, FetchOutcome::Downloaded { .. }));
assert_eq!(srv.hits(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fetch_falls_through_404_to_next_url() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data = b"payload".to_vec();
let first = TestServer::start(HashMap::new()); let second = TestServer::start(HashMap::from([("f.bin".to_string(), data.clone())]));
let dir = tmpdir("fallthrough");
let e = entry(
"f.bin",
&data,
vec![first.url("f.bin"), second.url("f.bin")],
);
let out = manifest::fetch_static_file(&e, &dir, false).unwrap();
assert_eq!(
out,
FetchOutcome::Downloaded {
url: second.url("f.bin")
}
);
assert_eq!(first.hits(), 1);
assert_eq!(second.hits(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fetch_rejects_hash_mismatch_and_tries_next_url() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let good = b"correct bytes".to_vec();
let bad = b"corrupt bytes".to_vec(); let first = TestServer::start(HashMap::from([("f.bin".to_string(), bad)]));
let second = TestServer::start(HashMap::from([("f.bin".to_string(), good.clone())]));
let dir = tmpdir("mismatch");
let e = entry(
"f.bin",
&good,
vec![first.url("f.bin"), second.url("f.bin")],
);
let out = manifest::fetch_static_file(&e, &dir, false).unwrap();
assert_eq!(
out,
FetchOutcome::Downloaded {
url: second.url("f.bin")
}
);
assert_eq!(std::fs::read(dir.join("f.bin")).unwrap(), good);
assert!(
!dir.join("f.bin.part").exists(),
"corrupt partial must be removed"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fetch_reports_every_failed_source() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let a = TestServer::start(HashMap::new());
let b = TestServer::start(HashMap::from([("f.bin".to_string(), b"wrong".to_vec())]));
let dir = tmpdir("allfail");
let e = entry("f.bin", b"right", vec![a.url("f.bin"), b.url("f.bin")]);
let err = manifest::fetch_static_file(&e, &dir, false).unwrap_err();
match &err {
download::Error::AllSourcesFailed {
name,
attempts,
hint,
} => {
assert_eq!(name, "f.bin");
assert_eq!(attempts.len(), 2);
assert!(hint.is_none(), "{hint:?}");
assert!(attempts[0].starts_with(&a.url("f.bin")), "{}", attempts[0]);
assert!(attempts[1].starts_with(&b.url("f.bin")), "{}", attempts[1]);
assert!(attempts[1].contains("mismatch"), "{}", attempts[1]);
}
other => panic!("unexpected error {other}"),
}
assert!(!dir.join("f.bin").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn mirror_override_is_tried_before_manifest_urls() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data = b"mirror payload".to_vec();
let mirror = TestServer::start(HashMap::from([("f.bin".to_string(), data.clone())]));
let official = TestServer::start(HashMap::from([("f.bin".to_string(), data.clone())]));
let dir = tmpdir("mirror");
let e = entry("f.bin", &data, vec![official.url("f.bin")]);
std::env::set_var(manifest::MIRROR_ENV, &mirror.base);
let out = manifest::fetch_static_file(&e, &dir, false);
std::env::remove_var(manifest::MIRROR_ENV);
assert_eq!(
out.unwrap(),
FetchOutcome::Downloaded {
url: mirror.url("f.bin")
}
);
assert_eq!(mirror.hits(), 1);
assert_eq!(
official.hits(),
0,
"official URL must not be contacted when the mirror works"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn existing_corrupt_file_is_replaced() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let data = b"fresh".to_vec();
let srv = TestServer::start(HashMap::from([("f.bin".to_string(), data.clone())]));
let dir = tmpdir("corrupt");
std::fs::write(dir.join("f.bin"), b"stale").unwrap(); let e = entry("f.bin", &data, vec![srv.url("f.bin")]);
let out = manifest::fetch_static_file(&e, &dir, false).unwrap();
assert!(matches!(out, FetchOutcome::Downloaded { .. }));
assert_eq!(std::fs::read(dir.join("f.bin")).unwrap(), data);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn concurrent_fetches_of_one_file_yield_one_verified_copy() {
let _guard = crate::utils::manifest::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let server = TestServer::start(HashMap::from([("big.bin".to_string(), bytes.clone())]));
let e = std::sync::Arc::new(entry("big.bin", &bytes, vec![server.url("big.bin")]));
let dir = std::sync::Arc::new(tmpdir("concurrent"));
let handles: Vec<_> = (0..8)
.map(|_| {
let (e, dir) = (e.clone(), dir.clone());
std::thread::spawn(move || {
crate::utils::manifest::fetch_static_file(&e, &dir, false)
})
})
.collect();
for h in handles {
let outcome = h.join().unwrap().expect("every concurrent fetch succeeds");
assert!(matches!(
outcome,
FetchOutcome::Downloaded { .. } | FetchOutcome::AlreadyPresent
));
}
assert!(
e.verify(&dir.join("big.bin")).unwrap(),
"final file verified"
);
let leftovers: Vec<String> = std::fs::read_dir(&*dir)
.unwrap()
.flatten()
.map(|d| d.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".part"))
.collect();
assert!(leftovers.is_empty(), "leftover temp files: {leftovers:?}");
assert!(server.hits() >= 1 && server.hits() <= 8);
let _ = std::fs::remove_dir_all(&*dir);
}
#[test]
fn on_disk_file_is_verified_once_via_sidecar_marker() {
use crate::utils::download::Error;
use crate::utils::manifest::Verified;
let bytes = b"correct contents of a pinned file".to_vec();
let e = entry(
"pinned.bin",
&bytes,
vec!["https://example.invalid/p".into()],
);
let dir = tmpdir("sidecar");
let path = dir.join("pinned.bin");
let marker = ManifestEntry::verified_marker_path(&path);
std::fs::write(&path, b"wrong!! contents of a pinned file").unwrap();
let err = e.ensure_verified(&path).unwrap_err();
assert!(
matches!(err, Error::CorruptFile { what: "sha256", .. }),
"{err}"
);
assert!(!marker.exists());
std::fs::write(&path, b"short").unwrap();
assert!(matches!(
e.ensure_verified(&path).unwrap_err(),
Error::CorruptFile { what: "size", .. }
));
std::fs::write(&path, &bytes).unwrap();
assert_eq!(e.ensure_verified(&path).unwrap(), Verified::Hashed);
assert!(marker.exists());
assert_eq!(e.ensure_verified(&path).unwrap(), Verified::Cached);
std::fs::write(&path, b"wrong!! contents of a pinned file").unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(5);
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(later)
.unwrap();
assert!(matches!(
e.ensure_verified(&path).unwrap_err(),
Error::CorruptFile { what: "sha256", .. }
));
std::fs::write(&path, &bytes).unwrap();
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(later + std::time::Duration::from_secs(5))
.unwrap();
assert_eq!(e.ensure_verified(&path).unwrap(), Verified::Hashed);
assert_eq!(e.ensure_verified(&path).unwrap(), Verified::Cached);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn proxy_env_is_accepted_by_the_agent() {
let _guard = crate::utils::manifest::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
std::env::set_var("HTTPS_PROXY", "http://proxy.invalid:3128");
let agent = crate::utils::download::http_agent();
let has_proxy = agent.config().proxy().is_some();
std::env::remove_var("HTTPS_PROXY");
assert!(
has_proxy,
"ureq should pick the proxy up from the environment"
);
}
#[test]
#[ignore = "requires network access; downloads ~110 MB"]
fn real_network_update_datafiles_into_tmp() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tmpdir("full");
let t0 = std::time::Instant::now();
update_datafiles(Some(dir.clone()), false).unwrap();
println!("update_datafiles took {:.1} s", t0.elapsed().as_secs_f64());
for e in manifest::embedded().default_files() {
assert!(
e.verify(&dir.join(&e.name)).unwrap(),
"{} not verified",
e.name
);
}
assert!(dir.join("EOP-All.csv").is_file() && dir.join("SW-All.csv").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "requires network access"]
fn real_network_fetch_smallest_file() {
let _guard = manifest::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let m = manifest::embedded();
let e = m.entry("tab5.2d.txt").unwrap();
let dir = tmpdir("net");
let out = manifest::fetch_static_file(e, &dir, false).unwrap();
println!("{out:?}");
assert!(e.verify(&dir.join("tab5.2d.txt")).unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
}