1use std::net::IpAddr;
40use std::path::PathBuf;
41use std::time::{Duration, SystemTime};
42
43use serde::{Deserialize, Serialize};
44
45use crate::error::{Error, Result};
46use crate::paths::user_data_dir;
47use crate::providers::ProviderPreset;
48
49pub const CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
51
52pub const DEFAULT_PROVIDERS_URL: &str =
56 "https://raw.githubusercontent.com/jeffkit/recursive-providers/main/providers.json";
57
58pub const PROVIDERS_URL_ENV: &str = "RECURSIVE_PROVIDERS_URL";
60
61#[derive(Debug, Serialize, Deserialize)]
63pub struct ProvidersCache {
64 pub schema_version: u32,
65 pub updated_at: String,
67 pub source_url: String,
69 pub providers: Vec<ProviderPreset>,
70}
71
72pub fn configured_url() -> String {
75 std::env::var(PROVIDERS_URL_ENV).unwrap_or_else(|_| DEFAULT_PROVIDERS_URL.to_string())
76}
77
78pub fn cache_path() -> PathBuf {
80 user_data_dir().join("providers_cache.json")
81}
82
83pub fn load_cache() -> Option<ProvidersCache> {
86 let path = cache_path();
87 if !path.exists() {
88 return None;
89 }
90 let content = std::fs::read_to_string(&path).ok()?;
91 serde_json::from_str(&content).ok()
92}
93
94pub fn needs_update() -> bool {
96 let path = cache_path();
97 let metadata = match std::fs::metadata(&path) {
98 Ok(m) => m,
99 Err(_) => return true,
100 };
101 let modified = match metadata.modified() {
102 Ok(t) => t,
103 Err(_) => return true,
104 };
105 SystemTime::now()
106 .duration_since(modified)
107 .map(|age| age > CACHE_TTL)
108 .unwrap_or(true)
109}
110
111pub fn validate_providers_url(url: &str) -> Result<()> {
117 if !url.starts_with("http://") && !url.starts_with("https://") {
118 return Err(Error::Config {
119 message: "providers URL must start with http:// or https://".into(),
120 });
121 }
122
123 let host = url
124 .split("://")
125 .nth(1)
126 .and_then(|rest| rest.split('/').next())
127 .and_then(|host_port| {
128 if host_port.starts_with('[') {
129 host_port.find(']').map(|i| &host_port[1..i])
130 } else {
131 host_port.split(':').next()
132 }
133 })
134 .unwrap_or("");
135
136 let host_lower = host.to_ascii_lowercase();
137 if host_lower == "localhost"
138 || host_lower.ends_with(".localhost")
139 || host_lower == "metadata.google.internal"
140 {
141 return Err(Error::Config {
142 message: format!("SSRF protection: host '{host}' is not allowed"),
143 });
144 }
145
146 if let Ok(ip) = host.parse::<IpAddr>() {
147 if is_non_routable_ip(ip) {
148 return Err(Error::Config {
149 message: format!("SSRF protection: IP address '{ip}' is not routable"),
150 });
151 }
152 }
153
154 Ok(())
155}
156
157fn is_non_routable_ip(ip: IpAddr) -> bool {
163 match ip {
164 IpAddr::V4(v4) => {
165 v4.is_loopback()
166 || v4.is_private()
167 || v4.is_link_local()
168 || v4.is_broadcast()
169 || v4.is_unspecified()
170 }
171 IpAddr::V6(v6) => {
172 v6.is_loopback()
173 || v6.is_unspecified()
174 || v6.is_multicast()
175 || (v6.segments()[0] & 0xfe00) == 0xfc00
176 || (v6.segments()[0] & 0xffc0) == 0xfe80
177 }
178 }
179}
180
181pub async fn fetch_and_save(url: &str) -> Result<ProvidersCache> {
187 validate_providers_url(url)?;
188
189 let client = reqwest::Client::builder()
190 .timeout(Duration::from_secs(15))
191 .build()
192 .map_err(Error::Http)?;
193
194 let body = client
195 .get(url)
196 .header(
197 "User-Agent",
198 concat!("recursive-agent/", env!("CARGO_PKG_VERSION")),
199 )
200 .send()
201 .await
202 .map_err(Error::Http)?
203 .error_for_status()
204 .map_err(Error::Http)?
205 .text()
206 .await
207 .map_err(Error::Http)?;
208
209 let cache: ProvidersCache = serde_json::from_str(&body).map_err(|e| Error::Config {
210 message: format!("providers JSON parse error: {e}"),
211 })?;
212
213 if cache.schema_version != 1 {
214 return Err(Error::Config {
215 message: format!(
216 "unsupported providers cache schema version: {}",
217 cache.schema_version
218 ),
219 });
220 }
221
222 let path = cache_path();
223 if let Some(parent) = path.parent() {
224 std::fs::create_dir_all(parent).map_err(Error::Io)?;
225 }
226 let serialized = serde_json::to_string_pretty(&cache).map_err(|e| Error::Config {
227 message: format!("failed to serialize cache: {e}"),
228 })?;
229 std::fs::write(&path, serialized).map_err(Error::Io)?;
230
231 Ok(cache)
232}
233
234pub fn merge_over_bundled(
239 bundled: &[ProviderPreset],
240 cache: &[ProviderPreset],
241) -> Vec<ProviderPreset> {
242 let mut result: Vec<ProviderPreset> = cache.to_vec();
243 for bp in bundled {
244 if !result.iter().any(|cp| cp.id == bp.id) {
245 result.push(bp.clone());
246 }
247 }
248 result
249}
250
251pub fn spawn_background_refresh() {
260 if !needs_update() {
261 return;
262 }
263 if std::env::var("RECURSIVE_PROVIDERS_AUTO_REFRESH")
264 .map(|v| v != "1")
265 .unwrap_or(true)
266 {
267 return;
268 }
269 let url = configured_url();
270 tokio::spawn(async move {
271 match fetch_and_save(&url).await {
272 Ok(cache) => {
273 tracing::debug!(
274 "providers cache refreshed: {} presets from {}",
275 cache.providers.len(),
276 cache.source_url
277 );
278 }
279 Err(e) => {
280 tracing::debug!("providers cache refresh failed (non-fatal): {e}");
281 }
282 }
283 });
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::providers::{all_presets, ModelPricingSpec, ModelSpec};
290 use crate::test_util::PinnedRecursiveHome;
291
292 #[test]
293 fn merge_overrides_bundled() {
294 let bundled = all_presets();
295 let mut fake = bundled[0].clone();
296 fake.default_model = "fake-model-override".to_string();
297 let merged = merge_over_bundled(bundled, &[fake]);
298 assert_eq!(merged[0].default_model, "fake-model-override");
299 assert_eq!(merged.len(), bundled.len());
300 }
301
302 #[test]
303 fn merge_adds_new_preset() {
304 let bundled = all_presets();
305 let new_preset = ProviderPreset {
306 id: "test-only-provider".to_string(),
307 name: "Test".to_string(),
308 provider_type: "openai".to_string(),
309 api_base: "https://test.example.com/v1".to_string(),
310 anthropic_api_base: None,
311 default_model: "test-model".to_string(),
312 models: vec![ModelSpec {
313 name: "test-model".to_string(),
314 context_window: 4096,
315 max_tokens: None,
316 pricing: Some(ModelPricingSpec {
317 input_per_million: 0.10,
318 output_per_million: 0.20,
319 cache_hit_input_per_million: None,
320 }),
321 }],
322 mainland_accessible: false,
323 key_env: "TEST_API_KEY".to_string(),
324 key_url: "https://test.example.com/keys".to_string(),
325 };
326 let merged = merge_over_bundled(bundled, &[new_preset]);
327 assert_eq!(merged.len(), bundled.len() + 1);
328 assert!(merged.iter().any(|p| p.id == "test-only-provider"));
329 }
330
331 #[test]
332 fn merge_empty_cache_returns_bundled() {
333 let bundled = all_presets();
334 let merged = merge_over_bundled(bundled, &[]);
335 assert_eq!(merged.len(), bundled.len());
336 assert_eq!(merged[0].id, bundled[0].id);
337 }
338
339 #[test]
340 fn needs_update_true_when_no_cache() -> Result<(), Box<dyn std::error::Error>> {
341 let tmp = tempfile::tempdir()?;
342 let _pin = PinnedRecursiveHome::new(tmp.path());
343 assert!(needs_update());
344 Ok(())
345 }
346
347 #[test]
348 fn validate_url_accepts_https_public_host() {
349 assert!(validate_providers_url(DEFAULT_PROVIDERS_URL).is_ok());
350 assert!(validate_providers_url("https://example.com/x.json").is_ok());
351 }
352
353 #[test]
354 fn validate_url_rejects_non_http_scheme() {
355 assert!(validate_providers_url("ftp://example.com/x").is_err());
356 assert!(validate_providers_url("example.com/x").is_err());
357 }
358
359 #[test]
360 fn validate_url_rejects_ssrf_targets() {
361 assert!(validate_providers_url("http://127.0.0.1/x").is_err());
362 assert!(validate_providers_url("http://localhost/x").is_err());
363 assert!(validate_providers_url("http://10.0.0.1/x").is_err());
364 assert!(validate_providers_url("http://169.254.169.254/x").is_err());
365 assert!(validate_providers_url("http://[::1]/x").is_err());
366 assert!(validate_providers_url("http://metadata.google.internal/x").is_err());
367 }
368
369 #[test]
370 fn load_cache_returns_none_when_absent() -> Result<(), Box<dyn std::error::Error>> {
371 let tmp = tempfile::tempdir()?;
372 let _pin = PinnedRecursiveHome::new(tmp.path());
373 assert!(load_cache().is_none());
374 Ok(())
375 }
376
377 #[test]
378 fn fetch_roundtrips_through_cache_file() -> Result<(), Box<dyn std::error::Error>> {
379 let tmp = tempfile::tempdir()?;
384 let _pin = PinnedRecursiveHome::new(tmp.path());
385
386 let cache = ProvidersCache {
387 schema_version: 1,
388 updated_at: "2026-06-06T00:00:00Z".to_string(),
389 source_url: "https://example.com/providers.json".to_string(),
390 providers: vec![ProviderPreset {
391 id: "roundtrip".to_string(),
392 name: "Roundtrip".to_string(),
393 provider_type: "openai".to_string(),
394 api_base: "https://rt.example.com/v1".to_string(),
395 anthropic_api_base: None,
396 default_model: "rt-1".to_string(),
397 models: vec![],
398 mainland_accessible: false,
399 key_env: "RT_KEY".to_string(),
400 key_url: "https://rt.example.com/keys".to_string(),
401 }],
402 };
403 let path = cache_path();
404 std::fs::create_dir_all(path.parent().unwrap())?;
405 std::fs::write(&path, serde_json::to_string_pretty(&cache)?)?;
406
407 let loaded = load_cache().expect("cache should load");
408 assert_eq!(loaded.schema_version, 1);
409 assert_eq!(loaded.providers.len(), 1);
410 assert_eq!(loaded.providers[0].id, "roundtrip");
411 Ok(())
412 }
413
414 #[test]
417 fn cache_ttl_is_seven_days() {
418 let seven_days = Duration::from_secs(7 * 24 * 60 * 60);
419 assert_eq!(CACHE_TTL, seven_days, "CACHE_TTL must be exactly 7 days");
420 }
421
422 #[test]
423 fn configured_url_returns_default_when_env_unset() {
424 let _lock = crate::test_util::env_lock();
425 std::env::remove_var(PROVIDERS_URL_ENV);
426 let url = configured_url();
427 assert_eq!(
428 url, DEFAULT_PROVIDERS_URL,
429 "must return default when env var unset"
430 );
431 }
432
433 #[test]
434 fn configured_url_honors_env_override() {
435 let _lock = crate::test_util::env_lock();
436 let custom = "https://custom.example.com/providers.json";
437 std::env::set_var(PROVIDERS_URL_ENV, custom);
438 let url = configured_url();
439 std::env::remove_var(PROVIDERS_URL_ENV);
440 assert_eq!(url, custom);
441 }
442
443 #[test]
444 fn validate_url_rejects_192_168_private() {
445 assert!(validate_providers_url("http://192.168.1.1/x").is_err());
446 }
447
448 #[test]
449 fn validate_url_rejects_172_16_private() {
450 assert!(validate_providers_url("http://172.16.0.1/x").is_err());
451 }
452
453 #[test]
454 fn validate_url_rejects_ipv6_loopback() {
455 assert!(validate_providers_url("http://[::1]/x").is_err());
456 }
457
458 #[test]
459 fn validate_url_accepts_http_public() {
460 assert!(validate_providers_url("http://example.com/providers.json").is_ok());
461 }
462
463 #[test]
464 fn is_non_routable_ip_loopback_true() {
465 use std::net::IpAddr;
466 let ip: IpAddr = "127.0.0.1".parse().unwrap();
467 assert!(is_non_routable_ip(ip));
468 }
469
470 #[test]
471 fn is_non_routable_ip_private_true() {
472 use std::net::IpAddr;
473 let ip: IpAddr = "10.0.0.1".parse().unwrap();
474 assert!(is_non_routable_ip(ip));
475 let ip2: IpAddr = "192.168.0.1".parse().unwrap();
476 assert!(is_non_routable_ip(ip2));
477 }
478
479 #[test]
480 fn is_non_routable_ip_public_false() {
481 use std::net::IpAddr;
482 let ip: IpAddr = "8.8.8.8".parse().unwrap();
483 assert!(!is_non_routable_ip(ip));
484 }
485
486 #[test]
487 fn is_non_routable_ip_ipv4_unspecified_true() {
488 use std::net::IpAddr;
490 let ip: IpAddr = "0.0.0.0".parse().unwrap();
491 assert!(is_non_routable_ip(ip), "0.0.0.0 must be non-routable");
492 }
493
494 #[test]
495 fn is_non_routable_ip_ipv4_link_local_true() {
496 use std::net::IpAddr;
498 let ip: IpAddr = "169.254.1.1".parse().unwrap();
499 assert!(
500 is_non_routable_ip(ip),
501 "link-local 169.254.x.x must be non-routable"
502 );
503 }
504
505 #[test]
506 fn is_non_routable_ip_ipv6_multicast_true() {
507 use std::net::IpAddr;
509 let ip: IpAddr = "ff02::1".parse().unwrap();
510 assert!(
511 is_non_routable_ip(ip),
512 "IPv6 multicast must be non-routable"
513 );
514 }
515
516 #[test]
517 fn is_non_routable_ip_ipv6_ula_true() {
518 use std::net::IpAddr;
520 let ip: IpAddr = "fd12:3456:789a::1".parse().unwrap();
521 assert!(
522 is_non_routable_ip(ip),
523 "IPv6 ULA fd::/8 must be non-routable"
524 );
525 }
526
527 #[test]
528 fn is_non_routable_ip_ipv6_link_local_true() {
529 use std::net::IpAddr;
531 let ip: IpAddr = "fe80::1".parse().unwrap();
532 assert!(
533 is_non_routable_ip(ip),
534 "IPv6 link-local fe80::/10 must be non-routable"
535 );
536 }
537
538 #[test]
539 fn is_non_routable_ip_ipv6_public_false() {
540 use std::net::IpAddr;
542 let ip: IpAddr = "2607:f8b0:4004:811::200e".parse().unwrap();
543 assert!(
544 !is_non_routable_ip(ip),
545 "global unicast IPv6 must be routable"
546 );
547 }
548
549 #[test]
550 fn needs_update_false_when_fresh_cache() -> Result<(), Box<dyn std::error::Error>> {
551 let tmp = tempfile::tempdir()?;
552 let _pin = PinnedRecursiveHome::new(tmp.path());
553
554 let path = cache_path();
556 std::fs::create_dir_all(path.parent().unwrap())?;
557 let cache = ProvidersCache {
558 schema_version: 1,
559 updated_at: "2026-01-01T00:00:00Z".to_string(),
560 source_url: "https://example.com/providers.json".to_string(),
561 providers: vec![],
562 };
563 std::fs::write(&path, serde_json::to_string_pretty(&cache)?)?;
564
565 assert!(
567 !needs_update(),
568 "freshly written cache must not need update"
569 );
570 Ok(())
571 }
572}