use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use url::Url;
use crate::http::RemoteInfo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Admission {
Primary,
Verified,
ChecksumGuarded,
Rejected(String),
}
impl Admission {
pub fn is_usable(&self) -> bool {
!matches!(self, Admission::Rejected(_))
}
}
pub fn classify(primary: &RemoteInfo, candidate: &RemoteInfo, has_checksum: bool) -> Admission {
match (primary.size, candidate.size) {
(Some(a), Some(b)) if a != b => {
return Admission::Rejected(format!("size differs ({b} vs {a} bytes)"));
}
(Some(_), None) => {
return Admission::Rejected("mirror did not report a size".into());
}
_ => {}
}
if !candidate.accept_ranges && primary.accept_ranges {
return Admission::Rejected("mirror does not support range requests".into());
}
if primary.has_strong_etag() && candidate.has_strong_etag() {
if primary.etag == candidate.etag {
return Admission::Verified;
}
if has_checksum {
return Admission::ChecksumGuarded;
}
return Admission::Rejected(
"ETag differs from the primary; pass a checksum to allow it anyway".into(),
);
}
if has_checksum {
Admission::ChecksumGuarded
} else {
Admission::Rejected(
"cannot prove this is the same file (no strong ETag on both sides and no checksum given)"
.into(),
)
}
}
pub struct Source {
pub url: Url,
pub admission: Admission,
pub validator: Option<String>,
failures: AtomicU32,
}
impl Source {
pub fn new(url: Url, admission: Admission, validator: Option<String>) -> Self {
Self {
url,
admission,
validator,
failures: AtomicU32::new(0),
}
}
pub fn failures(&self) -> u32 {
self.failures.load(Ordering::Relaxed)
}
}
pub struct SourceSet {
sources: Vec<Source>,
cursor: Mutex<usize>,
}
impl SourceSet {
pub fn new(sources: Vec<Source>) -> Self {
let sources: Vec<Source> = sources
.into_iter()
.filter(|s| s.admission.is_usable())
.collect();
Self {
sources,
cursor: Mutex::new(0),
}
}
pub fn single(url: Url, validator: Option<String>) -> Self {
Self::new(vec![Source::new(url, Admission::Primary, validator)])
}
pub fn len(&self) -> usize {
self.sources.len()
}
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
pub fn urls(&self) -> Vec<String> {
self.sources.iter().map(|s| s.url.to_string()).collect()
}
pub fn pick(&self) -> (usize, &Source) {
let min = self.sources.iter().map(|s| s.failures()).min().unwrap_or(0);
let candidates: Vec<usize> = self
.sources
.iter()
.enumerate()
.filter(|(_, s)| s.failures() == min)
.map(|(i, _)| i)
.collect();
let mut cursor = self.cursor.lock().unwrap_or_else(|e| e.into_inner());
*cursor = cursor.wrapping_add(1);
let idx = candidates[*cursor % candidates.len()];
(idx, &self.sources[idx])
}
pub fn penalise(&self, idx: usize) {
if let Some(s) = self.sources.get(idx) {
s.failures.fetch_add(1, Ordering::Relaxed);
}
}
pub fn reward(&self, idx: usize) {
if let Some(s) = self.sources.get(idx) {
let _ = s
.failures
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some(v.saturating_sub(1))
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(size: Option<u64>, etag: Option<&str>) -> RemoteInfo {
RemoteInfo {
final_url: Url::parse("https://a.example/f").unwrap(),
size,
accept_ranges: true,
etag: etag.map(String::from),
last_modified: None,
content_type: None,
content_disposition: None,
content_encoding: None,
}
}
#[test]
fn accepts_matching_strong_etags() {
let a = info(Some(100), Some("\"x\""));
let b = info(Some(100), Some("\"x\""));
assert_eq!(classify(&a, &b, false), Admission::Verified);
}
#[test]
fn rejects_size_mismatch_even_with_a_checksum() {
let a = info(Some(100), Some("\"x\""));
let b = info(Some(101), Some("\"x\""));
assert!(matches!(classify(&a, &b, true), Admission::Rejected(_)));
}
#[test]
fn rejects_differing_etags_without_a_checksum() {
let a = info(Some(100), Some("\"x\""));
let b = info(Some(100), Some("\"y\""));
assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
assert_eq!(classify(&a, &b, true), Admission::ChecksumGuarded);
}
#[test]
fn rejects_unprovable_equivalence() {
let a = info(Some(100), None);
let b = info(Some(100), None);
assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
assert_eq!(classify(&a, &b, true), Admission::ChecksumGuarded);
}
#[test]
fn rejects_weak_etags_as_proof() {
let a = info(Some(100), Some("W/\"x\""));
let b = info(Some(100), Some("W/\"x\""));
assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
}
#[test]
fn rejects_mirror_without_range_support() {
let a = info(Some(100), Some("\"x\""));
let mut b = info(Some(100), Some("\"x\""));
b.accept_ranges = false;
assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
}
#[test]
fn drops_rejected_sources() {
let set = SourceSet::new(vec![
Source::new(
Url::parse("https://a.example/f").unwrap(),
Admission::Primary,
None,
),
Source::new(
Url::parse("https://b.example/f").unwrap(),
Admission::Rejected("nope".into()),
None,
),
]);
assert_eq!(set.len(), 1);
}
#[test]
fn selection_avoids_failing_mirrors() {
let set = SourceSet::new(vec![
Source::new(
Url::parse("https://a.example/f").unwrap(),
Admission::Primary,
Some("\"a\"".into()),
),
Source::new(
Url::parse("https://b.example/f").unwrap(),
Admission::Verified,
Some("\"b\"".into()),
),
]);
set.penalise(0);
for _ in 0..6 {
let (idx, source) = set.pick();
assert_eq!(
idx, 1,
"should avoid the failing mirror, got {}",
source.url
);
assert_eq!(source.validator.as_deref(), Some("\"b\""));
}
set.reward(0);
let picks: std::collections::HashSet<usize> = (0..8).map(|_| set.pick().0).collect();
assert_eq!(picks.len(), 2);
}
#[test]
fn single_source_is_always_picked() {
let set = SourceSet::single(Url::parse("https://a.example/f").unwrap(), None);
set.penalise(0);
assert_eq!(set.pick().0, 0);
}
}