use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use ureq::http::Uri;
use crate::error::{Context, Result};
use crate::net::{Http, NetPolicy, check_uri_shape};
use crate::{bail, config, err, html, libgen};
pub const SEED_MIRRORS: &[&str] = &[
"https://libgen.li",
"https://libgen.gl",
"https://libgen.vg",
"https://libgen.la",
"https://libgen.bz",
"https://libgen.gs",
"https://libgen.is",
"https://libgen.rs",
"https://libgen.st",
];
const CACHE_TTL: Duration = Duration::from_secs(6 * 60 * 60);
const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, Clone)]
pub struct MirrorStatus {
pub base: Uri,
pub outcome: Outcome,
}
#[derive(Debug, Clone)]
pub enum Outcome {
Healthy {
latency: Duration,
results: usize,
},
Unhealthy(String),
}
impl MirrorStatus {
pub fn is_healthy(&self) -> bool {
matches!(self.outcome, Outcome::Healthy { .. })
}
pub fn latency(&self) -> Option<Duration> {
match self.outcome {
Outcome::Healthy { latency, .. } => Some(latency),
Outcome::Unhealthy(_) => None,
}
}
}
pub fn parse_mirror(raw: &str, policy: &NetPolicy) -> Result<Uri> {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
bail!("empty mirror address");
}
let with_scheme = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let uri: Uri = format!("{with_scheme}/")
.parse()
.with_context(|| format!("`{raw}` is not a valid URL"))?;
check_uri_shape(&uri, policy)?;
Ok(uri)
}
pub fn probe_all(candidates: &[Uri], policy: &NetPolicy) -> Vec<MirrorStatus> {
let probe_policy = NetPolicy {
request_timeout: PROBE_TIMEOUT,
connect_timeout: Duration::from_secs(6),
..*policy
};
std::thread::scope(|scope| {
let handles: Vec<_> = candidates
.iter()
.map(|base| {
scope.spawn(move || {
let started = Instant::now();
let outcome =
match Http::new(probe_policy).and_then(|http| libgen::probe(&http, base)) {
Ok(results) => Outcome::Healthy {
latency: started.elapsed(),
results,
},
Err(e) => Outcome::Unhealthy(short_reason(&e.to_string())),
};
MirrorStatus {
base: base.clone(),
outcome,
}
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
})
}
fn short_reason(message: &str) -> String {
let first = message
.split(&[':', '\n'][..])
.next_back()
.unwrap_or(message)
.trim();
let text = if first.len() < 8 { message } else { first };
let text = text.trim();
if text.len() > 60 {
format!("{}…", &text[..text.floor_char_boundary(60)])
} else {
text.to_string()
}
}
pub fn rank(statuses: &[MirrorStatus]) -> Vec<Uri> {
let mut healthy: Vec<&MirrorStatus> = statuses.iter().filter(|s| s.is_healthy()).collect();
healthy.sort_by_key(|s| s.latency().unwrap_or(Duration::MAX));
healthy.iter().map(|s| s.base.clone()).collect()
}
pub fn discover(http: &Http, base: &Uri, policy: &NetPolicy) -> Vec<Uri> {
let Ok(url) = crate::net::join_uri(base, "mirrors.php") else {
return Vec::new();
};
let Ok(page) = http.get_text(&url) else {
return Vec::new();
};
let mut found = Vec::new();
for href in html::hrefs(&page) {
if !href.starts_with("http") {
continue;
}
let Ok(uri) = parse_mirror(&href, policy) else {
continue;
};
let Ok(host) = crate::net::host_of(&uri) else {
continue;
};
if !looks_like_libgen(&host) {
continue;
}
let normalized = format!("https://{host}/");
if let Ok(u) = normalized.parse::<Uri>()
&& !found.iter().any(|e: &Uri| e.to_string() == u.to_string())
{
found.push(u);
}
}
found
}
fn looks_like_libgen(host: &str) -> bool {
let host = host.to_ascii_lowercase();
host.contains("libgen") || host.contains("genesis")
}
pub struct Pool {
ordered: Vec<Uri>,
}
impl Pool {
pub fn new(ordered: Vec<Uri>) -> Self {
Self { ordered }
}
pub fn mirrors(&self) -> &[Uri] {
&self.ordered
}
pub fn try_each<T, F, R>(&self, mut op: F, mut on_retry: R) -> Result<(T, Uri)>
where
F: FnMut(&Uri) -> Result<T>,
R: FnMut(&Uri, &str),
{
if self.ordered.is_empty() {
bail!("no usable Libgen mirror found");
}
let mut failures = Vec::new();
for base in &self.ordered {
match op(base) {
Ok(value) => return Ok((value, base.clone())),
Err(e) => {
let host = crate::net::host_of(base).unwrap_or_else(|_| base.to_string());
on_retry(base, &e.to_string());
failures.push(format!(" {host}: {}", short_reason(&e.to_string())));
}
}
}
Err(err!(
"every mirror failed:\n{}\n\nTry `tomesole mirrors --refresh` to re-probe.",
failures.join("\n")
))
}
}
pub struct ResolveOptions<'a> {
pub explicit: &'a [String],
pub refresh: bool,
pub progress: &'a dyn Fn(&str),
}
pub fn resolve(policy: &NetPolicy, opts: ResolveOptions<'_>) -> Result<Pool> {
if !opts.explicit.is_empty() {
let mut ordered = Vec::new();
for raw in opts.explicit {
ordered.push(parse_mirror(raw, policy)?);
}
return Ok(Pool::new(ordered));
}
if !opts.refresh
&& let Some(cached) = load_cache(policy)
&& !cached.is_empty()
{
return Ok(Pool::new(cached));
}
(opts.progress)("checking mirrors");
let seeds: Vec<Uri> = SEED_MIRRORS
.iter()
.filter_map(|s| parse_mirror(s, policy).ok())
.collect();
let statuses = probe_all(&seeds, policy);
let mut ordered = rank(&statuses);
if ordered.is_empty() {
(opts.progress)("no seed mirror responded — discovering alternatives");
let discovered = discover_from_any(&seeds, policy);
if !discovered.is_empty() {
let statuses = probe_all(&discovered, policy);
ordered = rank(&statuses);
}
}
if ordered.is_empty() {
let detail = statuses
.iter()
.filter_map(|s| match &s.outcome {
Outcome::Unhealthy(reason) => Some(format!(
" {}: {reason}",
crate::net::host_of(&s.base).unwrap_or_default()
)),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
bail!(
"no working Libgen mirror found.\n{detail}\n\n\
Libgen mirrors move often. If you know a current one, use \
`--mirror <url>` or set it in {}.",
config::config_path().display()
);
}
save_cache(&ordered);
Ok(Pool::new(ordered))
}
fn discover_from_any(seeds: &[Uri], policy: &NetPolicy) -> Vec<Uri> {
let Ok(http) = Http::new(NetPolicy {
request_timeout: PROBE_TIMEOUT,
..*policy
}) else {
return Vec::new();
};
for seed in seeds {
let found = discover(&http, seed, policy);
if !found.is_empty() {
return found;
}
}
Vec::new()
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn load_cache(policy: &NetPolicy) -> Option<Vec<Uri>> {
let text = std::fs::read_to_string(config::mirror_cache_path()).ok()?;
let mut lines = text.lines();
let stamp: u64 = lines.next()?.trim().parse().ok()?;
let age = now_secs().saturating_sub(stamp);
if age > CACHE_TTL.as_secs() {
return None;
}
let mirrors: Vec<Uri> = lines
.filter_map(|line| {
let url = line.split('\t').next()?.trim();
if url.is_empty() {
return None;
}
parse_mirror(url, policy).ok()
})
.collect();
if mirrors.is_empty() {
None
} else {
Some(mirrors)
}
}
fn save_cache(ordered: &[Uri]) {
let dir = config::cache_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let mut out = format!("{}\n", now_secs());
for uri in ordered {
out.push_str(&format!("{uri}\n"));
}
let _ = std::fs::write(config::mirror_cache_path(), out);
}
pub fn clear_cache() -> Result<()> {
let path = config::mirror_cache_path();
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("could not remove {}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> NetPolicy {
NetPolicy::default()
}
#[test]
fn parses_mirrors_in_several_forms() {
assert_eq!(
parse_mirror("libgen.li", &policy()).unwrap().to_string(),
"https://libgen.li/"
);
assert_eq!(
parse_mirror("https://libgen.li/", &policy())
.unwrap()
.to_string(),
"https://libgen.li/"
);
assert_eq!(
parse_mirror(" https://libgen.li ", &policy())
.unwrap()
.to_string(),
"https://libgen.li/"
);
}
#[test]
fn mirror_parsing_enforces_the_network_policy() {
assert!(parse_mirror("http://libgen.li", &policy()).is_err());
assert!(parse_mirror("https://localhost", &policy()).is_err());
assert!(parse_mirror("https://127.0.0.1", &policy()).is_err());
assert!(parse_mirror("ftp://libgen.li", &policy()).is_err());
assert!(parse_mirror("", &policy()).is_err());
}
#[test]
fn private_mirrors_allowed_only_with_opt_in() {
let relaxed = NetPolicy {
allow_private_hosts: true,
allow_http: true,
..policy()
};
assert!(parse_mirror("http://localhost:8080", &relaxed).is_ok());
}
#[test]
fn every_seed_mirror_is_well_formed() {
for seed in SEED_MIRRORS {
assert!(parse_mirror(seed, &policy()).is_ok(), "bad seed: {seed}");
}
}
#[test]
fn ranking_puts_fastest_healthy_first_and_drops_failures() {
let mk = |url: &str, outcome: Outcome| MirrorStatus {
base: parse_mirror(url, &policy()).unwrap(),
outcome,
};
let statuses = vec![
mk(
"a.libgen.example",
Outcome::Healthy {
latency: Duration::from_millis(900),
results: 25,
},
),
mk("b.libgen.example", Outcome::Unhealthy("HTTP 500".into())),
mk(
"c.libgen.example",
Outcome::Healthy {
latency: Duration::from_millis(120),
results: 25,
},
),
];
let ranked = rank(&statuses);
assert_eq!(ranked.len(), 2, "unhealthy mirrors are dropped");
assert_eq!(ranked[0].host().unwrap(), "c.libgen.example");
assert_eq!(ranked[1].host().unwrap(), "a.libgen.example");
}
#[test]
fn pool_falls_through_to_the_next_mirror() {
let pool = Pool::new(vec![
parse_mirror("dead.libgen.example", &policy()).unwrap(),
parse_mirror("live.libgen.example", &policy()).unwrap(),
]);
let mut retried = Vec::new();
let (value, used) = pool
.try_each(
|base| {
if base.host().unwrap().starts_with("dead") {
Err(err!("boom"))
} else {
Ok(42)
}
},
|base, _| retried.push(base.host().unwrap().to_string()),
)
.unwrap();
assert_eq!(value, 42);
assert_eq!(used.host().unwrap(), "live.libgen.example");
assert_eq!(retried, ["dead.libgen.example"]);
}
#[test]
fn pool_reports_all_failures_when_nothing_works() {
let pool = Pool::new(vec![
parse_mirror("a.libgen.example", &policy()).unwrap(),
parse_mirror("b.libgen.example", &policy()).unwrap(),
]);
let result: Result<(i32, Uri)> = pool.try_each(|_| Err(err!("HTTP 500")), |_, _| {});
let message = result.unwrap_err().to_string();
assert!(message.contains("a.libgen.example"), "{message}");
assert!(message.contains("b.libgen.example"), "{message}");
}
#[test]
fn empty_pool_is_an_error() {
let pool = Pool::new(Vec::new());
let result: Result<(i32, Uri)> = pool.try_each(|_| Ok(1), |_, _| {});
assert!(result.is_err());
}
#[test]
fn discovery_only_accepts_plausible_libgen_hosts() {
assert!(looks_like_libgen("libgen.li"));
assert!(looks_like_libgen("libgen-mirror.example"));
assert!(looks_like_libgen("library.genesis.example"));
assert!(!looks_like_libgen("ads.doubleclick.net"));
assert!(!looks_like_libgen("evil.example"));
}
#[test]
fn long_error_messages_are_shortened_for_display() {
let long = "a".repeat(200);
assert!(short_reason(&long).chars().count() <= 61);
assert_eq!(short_reason("request failed: HTTP 500"), "HTTP 500");
}
}