1use std::sync::Mutex;
14use std::sync::atomic::{AtomicU32, Ordering};
15
16use url::Url;
17
18use crate::http::RemoteInfo;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Admission {
22 Primary,
24 Verified,
26 ChecksumGuarded,
28 Rejected(String),
29}
30
31impl Admission {
32 pub fn is_usable(&self) -> bool {
33 !matches!(self, Admission::Rejected(_))
34 }
35}
36
37pub fn classify(primary: &RemoteInfo, candidate: &RemoteInfo, has_checksum: bool) -> Admission {
40 match (primary.size, candidate.size) {
41 (Some(a), Some(b)) if a != b => {
42 return Admission::Rejected(format!("size differs ({b} vs {a} bytes)"));
43 }
44 (Some(_), None) => {
45 return Admission::Rejected("mirror did not report a size".into());
46 }
47 _ => {}
48 }
49
50 if !candidate.accept_ranges && primary.accept_ranges {
51 return Admission::Rejected("mirror does not support range requests".into());
52 }
53
54 if primary.has_strong_etag() && candidate.has_strong_etag() {
55 if primary.etag == candidate.etag {
56 return Admission::Verified;
57 }
58 if has_checksum {
59 return Admission::ChecksumGuarded;
60 }
61 return Admission::Rejected(
62 "ETag differs from the primary; pass a checksum to allow it anyway".into(),
63 );
64 }
65
66 if has_checksum {
67 Admission::ChecksumGuarded
68 } else {
69 Admission::Rejected(
70 "cannot prove this is the same file (no strong ETag on both sides and no checksum given)"
71 .into(),
72 )
73 }
74}
75
76pub struct Source {
77 pub url: Url,
78 pub admission: Admission,
79 pub validator: Option<String>,
84 failures: AtomicU32,
85}
86
87impl Source {
88 pub fn new(url: Url, admission: Admission, validator: Option<String>) -> Self {
89 Self {
90 url,
91 admission,
92 validator,
93 failures: AtomicU32::new(0),
94 }
95 }
96
97 pub fn failures(&self) -> u32 {
98 self.failures.load(Ordering::Relaxed)
99 }
100}
101
102pub struct SourceSet {
105 sources: Vec<Source>,
106 cursor: Mutex<usize>,
107}
108
109impl SourceSet {
110 pub fn new(sources: Vec<Source>) -> Self {
112 let sources: Vec<Source> = sources
113 .into_iter()
114 .filter(|s| s.admission.is_usable())
115 .collect();
116 Self {
117 sources,
118 cursor: Mutex::new(0),
119 }
120 }
121
122 pub fn single(url: Url, validator: Option<String>) -> Self {
123 Self::new(vec![Source::new(url, Admission::Primary, validator)])
124 }
125
126 pub fn len(&self) -> usize {
127 self.sources.len()
128 }
129
130 pub fn is_empty(&self) -> bool {
131 self.sources.is_empty()
132 }
133
134 pub fn urls(&self) -> Vec<String> {
135 self.sources.iter().map(|s| s.url.to_string()).collect()
136 }
137
138 pub fn pick(&self) -> (usize, &Source) {
141 let min = self.sources.iter().map(|s| s.failures()).min().unwrap_or(0);
142 let candidates: Vec<usize> = self
143 .sources
144 .iter()
145 .enumerate()
146 .filter(|(_, s)| s.failures() == min)
147 .map(|(i, _)| i)
148 .collect();
149 let mut cursor = self.cursor.lock().unwrap_or_else(|e| e.into_inner());
150 *cursor = cursor.wrapping_add(1);
151 let idx = candidates[*cursor % candidates.len()];
152 (idx, &self.sources[idx])
153 }
154
155 pub fn penalise(&self, idx: usize) {
156 if let Some(s) = self.sources.get(idx) {
157 s.failures.fetch_add(1, Ordering::Relaxed);
158 }
159 }
160
161 pub fn reward(&self, idx: usize) {
162 if let Some(s) = self.sources.get(idx) {
163 let _ = s
166 .failures
167 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
168 Some(v.saturating_sub(1))
169 });
170 }
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn info(size: Option<u64>, etag: Option<&str>) -> RemoteInfo {
179 RemoteInfo {
180 final_url: Url::parse("https://a.example/f").unwrap(),
181 size,
182 accept_ranges: true,
183 etag: etag.map(String::from),
184 last_modified: None,
185 content_type: None,
186 content_disposition: None,
187 content_encoding: None,
188 }
189 }
190
191 #[test]
192 fn accepts_matching_strong_etags() {
193 let a = info(Some(100), Some("\"x\""));
194 let b = info(Some(100), Some("\"x\""));
195 assert_eq!(classify(&a, &b, false), Admission::Verified);
196 }
197
198 #[test]
199 fn rejects_size_mismatch_even_with_a_checksum() {
200 let a = info(Some(100), Some("\"x\""));
201 let b = info(Some(101), Some("\"x\""));
202 assert!(matches!(classify(&a, &b, true), Admission::Rejected(_)));
203 }
204
205 #[test]
206 fn rejects_differing_etags_without_a_checksum() {
207 let a = info(Some(100), Some("\"x\""));
208 let b = info(Some(100), Some("\"y\""));
209 assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
210 assert_eq!(classify(&a, &b, true), Admission::ChecksumGuarded);
212 }
213
214 #[test]
215 fn rejects_unprovable_equivalence() {
216 let a = info(Some(100), None);
217 let b = info(Some(100), None);
218 assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
219 assert_eq!(classify(&a, &b, true), Admission::ChecksumGuarded);
220 }
221
222 #[test]
223 fn rejects_weak_etags_as_proof() {
224 let a = info(Some(100), Some("W/\"x\""));
225 let b = info(Some(100), Some("W/\"x\""));
226 assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
228 }
229
230 #[test]
231 fn rejects_mirror_without_range_support() {
232 let a = info(Some(100), Some("\"x\""));
233 let mut b = info(Some(100), Some("\"x\""));
234 b.accept_ranges = false;
235 assert!(matches!(classify(&a, &b, false), Admission::Rejected(_)));
236 }
237
238 #[test]
239 fn drops_rejected_sources() {
240 let set = SourceSet::new(vec![
241 Source::new(
242 Url::parse("https://a.example/f").unwrap(),
243 Admission::Primary,
244 None,
245 ),
246 Source::new(
247 Url::parse("https://b.example/f").unwrap(),
248 Admission::Rejected("nope".into()),
249 None,
250 ),
251 ]);
252 assert_eq!(set.len(), 1);
253 }
254
255 #[test]
256 fn selection_avoids_failing_mirrors() {
257 let set = SourceSet::new(vec![
258 Source::new(
259 Url::parse("https://a.example/f").unwrap(),
260 Admission::Primary,
261 Some("\"a\"".into()),
262 ),
263 Source::new(
264 Url::parse("https://b.example/f").unwrap(),
265 Admission::Verified,
266 Some("\"b\"".into()),
267 ),
268 ]);
269 set.penalise(0);
270 for _ in 0..6 {
271 let (idx, source) = set.pick();
272 assert_eq!(
273 idx, 1,
274 "should avoid the failing mirror, got {}",
275 source.url
276 );
277 assert_eq!(source.validator.as_deref(), Some("\"b\""));
279 }
280 set.reward(0);
282 let picks: std::collections::HashSet<usize> = (0..8).map(|_| set.pick().0).collect();
283 assert_eq!(picks.len(), 2);
284 }
285
286 #[test]
287 fn single_source_is_always_picked() {
288 let set = SourceSet::single(Url::parse("https://a.example/f").unwrap(), None);
289 set.penalise(0);
290 assert_eq!(set.pick().0, 0);
291 }
292}