Skip to main content

osdk_core/source/
mod.rs

1//! Multi-source model: each SDK ships a default list of sources (official +
2//! authoritative mirrors); users can add custom sources or pin one. The
3//! selection strategy (auto/pinned/ordered) is applied by [`select`].
4
5use serde::{Deserialize, Serialize};
6
7pub mod select;
8
9/// A URL template with `{version}`, `{os}`, `{arch}`, `{file}`, `{ext}`
10/// placeholders that backends substitute at download time.
11pub type UrlTemplate = String;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SourceKind {
16    /// The canonical upstream source.
17    Official,
18    /// A well-known mirror/proxy.
19    Mirror,
20    /// A user-provided source.
21    Custom,
22}
23
24/// A single download source for a tool.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Source {
27    pub id: String,
28    pub kind: SourceKind,
29    /// Version-index / metadata endpoint (may differ from the download host).
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub index_url: Option<UrlTemplate>,
32    /// Base URL for archive downloads.
33    pub download_url: UrlTemplate,
34    /// Extra request headers (e.g. a GitHub token for python-build-standalone).
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub headers: Vec<(String, String)>,
37    /// Whether provider credentials may be forwarded to this endpoint.
38    #[serde(default)]
39    pub forward_credentials: bool,
40    /// Lower is preferred when strategy is `ordered` and no probe data exists.
41    #[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
103/// A deterministic, secret-safe identity for a concrete source candidate set.
104///
105/// The fingerprint includes the fields that affect which endpoints are probed
106/// and how they are ordered, while hashing header values so secrets are never
107/// persisted in plain text.
108pub(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/// How to pick among sources.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
141#[serde(rename_all = "lowercase")]
142pub enum Selection {
143    /// Probe candidates and pick the fastest (cached with TTL).
144    #[default]
145    Auto,
146    /// Always use the pinned source id (falls back to ordered on failure).
147    Pinned,
148    /// Try in priority order, first reachable wins.
149    Ordered,
150}
151
152/// Result of a speed probe against one source, cached to disk.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ProbeResult {
155    pub source_id: String,
156    /// Measured throughput in bytes/sec (higher is better). 0 = failed.
157    pub throughput: f64,
158    /// Time-to-first-byte in milliseconds (lower is better, tiebreak).
159    pub ttfb_ms: u64,
160    /// Whether the probe succeeded.
161    pub ok: bool,
162    /// Unix epoch seconds when this probe was taken.
163    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    /// Score used for ranking: throughput primary, ttfb as tiebreak.
178    pub fn score(&self) -> f64 {
179        if !self.ok {
180            return f64::MIN;
181        }
182        // Throughput dominates; subtract a small ttfb penalty.
183        self.throughput - (self.ttfb_ms as f64)
184    }
185}
186
187/// Cached probe results for a tool, with a measured timestamp for TTL checks.
188#[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}