1use serde::{Deserialize, Serialize};
6
7pub mod select;
8
9pub type UrlTemplate = String;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SourceKind {
16 Official,
18 Mirror,
20 Custom,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Source {
27 pub id: String,
28 pub kind: SourceKind,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub index_url: Option<UrlTemplate>,
32 pub download_url: UrlTemplate,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub headers: Vec<(String, String)>,
37 #[serde(default)]
39 pub forward_credentials: bool,
40 #[serde(default)]
42 pub priority: i32,
43 #[serde(default = "default_true")]
44 pub enabled: bool,
45}
46
47fn default_true() -> bool {
48 true
49}
50
51#[derive(Serialize)]
52struct SourceFingerprint<'a> {
53 ordinal: usize,
54 id: &'a str,
55 kind: SourceKind,
56 index_url: Option<&'a str>,
57 download_url: &'a str,
58 priority: i32,
59 enabled: bool,
60 forward_credentials: bool,
61 headers: Vec<HeaderFingerprint>,
62}
63
64#[derive(Serialize)]
65struct HeaderFingerprint {
66 name: String,
67 value_hash: String,
68}
69
70impl Source {
71 pub fn official(id: &str, download_url: &str) -> Source {
72 Source {
73 id: id.to_string(),
74 kind: SourceKind::Official,
75 index_url: None,
76 download_url: download_url.to_string(),
77 headers: Vec::new(),
78 forward_credentials: true,
79 priority: 0,
80 enabled: true,
81 }
82 }
83
84 pub fn mirror(id: &str, download_url: &str, priority: i32) -> Source {
85 Source {
86 id: id.to_string(),
87 kind: SourceKind::Mirror,
88 index_url: None,
89 download_url: download_url.to_string(),
90 headers: Vec::new(),
91 forward_credentials: false,
92 priority,
93 enabled: true,
94 }
95 }
96
97 pub fn with_index(mut self, index_url: &str) -> Source {
98 self.index_url = Some(index_url.to_string());
99 self
100 }
101}
102
103pub(crate) fn candidate_fingerprint(sources: &[Source]) -> String {
109 let encoded = sources
110 .iter()
111 .enumerate()
112 .map(|(ordinal, source)| {
113 let mut headers: Vec<HeaderFingerprint> = source
114 .headers
115 .iter()
116 .map(|(name, value)| HeaderFingerprint {
117 name: name.to_ascii_lowercase(),
118 value_hash: blake3::hash(value.as_bytes()).to_hex().to_string(),
119 })
120 .collect();
121 headers.sort_by(|a, b| a.name.cmp(&b.name).then(a.value_hash.cmp(&b.value_hash)));
122 SourceFingerprint {
123 ordinal,
124 id: &source.id,
125 kind: source.kind,
126 index_url: source.index_url.as_deref(),
127 download_url: &source.download_url,
128 priority: source.priority,
129 enabled: source.enabled,
130 forward_credentials: source.forward_credentials,
131 headers,
132 }
133 })
134 .collect::<Vec<_>>();
135 let bytes = serde_json::to_vec(&encoded).unwrap_or_default();
136 blake3::hash(&bytes).to_hex().to_string()
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
141#[serde(rename_all = "lowercase")]
142pub enum Selection {
143 #[default]
145 Auto,
146 Pinned,
148 Ordered,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ProbeResult {
155 pub source_id: String,
156 pub throughput: f64,
158 pub ttfb_ms: u64,
160 pub ok: bool,
162 pub measured_at: u64,
164}
165
166impl ProbeResult {
167 pub fn failed(source_id: &str) -> ProbeResult {
168 ProbeResult {
169 source_id: source_id.to_string(),
170 throughput: 0.0,
171 ttfb_ms: u64::MAX,
172 ok: false,
173 measured_at: now_secs(),
174 }
175 }
176
177 pub fn score(&self) -> f64 {
179 if !self.ok {
180 return f64::MIN;
181 }
182 self.throughput - (self.ttfb_ms as f64)
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
189pub struct ProbeCache {
190 pub results: Vec<ProbeResult>,
191}
192
193pub fn now_secs() -> u64 {
194 std::time::SystemTime::now()
195 .duration_since(std::time::UNIX_EPOCH)
196 .map(|d| d.as_secs())
197 .unwrap_or(0)
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn score_prefers_higher_throughput() {
206 let a = ProbeResult {
207 source_id: "a".into(),
208 throughput: 100.0,
209 ttfb_ms: 50,
210 ok: true,
211 measured_at: 0,
212 };
213 let b = ProbeResult {
214 source_id: "b".into(),
215 throughput: 200.0,
216 ttfb_ms: 60,
217 ok: true,
218 measured_at: 0,
219 };
220 assert!(b.score() > a.score());
221 }
222
223 #[test]
224 fn failed_probe_scores_lowest() {
225 let f = ProbeResult::failed("x");
226 let ok = ProbeResult {
227 source_id: "y".into(),
228 throughput: 1.0,
229 ttfb_ms: 999,
230 ok: true,
231 measured_at: 0,
232 };
233 assert!(ok.score() > f.score());
234 }
235
236 #[test]
237 fn source_builders() {
238 let s = Source::mirror(
239 "tuna",
240 "https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/",
241 10,
242 )
243 .with_index("https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/index.json");
244 assert_eq!(s.kind, SourceKind::Mirror);
245 assert!(s.index_url.is_some());
246 assert!(s.enabled);
247 }
248
249 #[test]
250 fn probe_results_rank_best_first() {
251 let mut results = [
252 ProbeResult {
253 source_id: "slow".into(),
254 throughput: 10.0,
255 ttfb_ms: 500,
256 ok: true,
257 measured_at: 0,
258 },
259 ProbeResult::failed("dead"),
260 ProbeResult {
261 source_id: "fast".into(),
262 throughput: 5000.0,
263 ttfb_ms: 300,
264 ok: true,
265 measured_at: 0,
266 },
267 ];
268 results.sort_by(|a, b| b.score().total_cmp(&a.score()));
269 let order: Vec<&str> = results.iter().map(|r| r.source_id.as_str()).collect();
270 assert_eq!(order, vec!["fast", "slow", "dead"]);
271 }
272
273 #[test]
274 fn candidate_fingerprint_changes_when_hashed_headers_change() {
275 let mut source = Source::mirror("mirror", "https://mirror.example.test", 1);
276 source.headers = vec![("Authorization".into(), "Bearer secret-a".into())];
277 let first = candidate_fingerprint(&[source.clone()]);
278
279 source.headers = vec![("Authorization".into(), "Bearer secret-b".into())];
280 let second = candidate_fingerprint(&[source]);
281
282 assert_ne!(first, second);
283 assert!(!first.contains("secret-a"));
284 assert!(!second.contains("secret-b"));
285 }
286}