Skip to main content

recursive/
providers_cache.rs

1//! Remote provider preset catalog — download, cache, and merge with the
2//! bundled + `providers.d` preset sources.
3//!
4//! # Design
5//!
6//! The bundled `providers.toml` is the compile-time fallback. A JSON cache
7//! file at `<user_data_dir>/providers_cache.json` (written by
8//! `recursive providers update`) holds presets fetched from an upstream
9//! catalog URL. When the kernel resolves the *effective* preset list (see
10//! [`crate::providers::all_presets_effective`]), cached presets override
11//! bundled entries with the same `id` (so stale bundled pricing gets
12//! refreshed upstream), while bundled presets absent from the cache are
13//! kept as-is. User overrides from `providers.d/` are layered on top by
14//! [`crate::providers::all_presets_effective`].
15//!
16//! # Cache file format
17//!
18//! ```json
19//! {
20//!   "schema_version": 1,
21//!   "updated_at": "2026-06-06T00:00:00Z",
22//!   "source_url": "https://...",
23//!   "providers": [ ... ]
24//! }
25//! ```
26//!
27//! The `providers` array uses the same structure as [`ProviderPreset`] plus
28//! the same optional fields (`anthropic_api_base`, `pricing`, etc.).
29//!
30//! # Network policy
31//!
32//! The only network touch is [`fetch_and_save`], invoked explicitly by
33//! `recursive providers update` (and, if enabled, the best-effort
34//! [`spawn_background_refresh`]). Preset *lookup* never hits the network —
35//! [`load_cache`] reads a local JSON file. URLs are validated by
36//! [`validate_providers_url`] which mirrors the SSRF guard in
37//! `tools::web_fetch` (scheme + loopback/private/metadata blocking).
38
39use 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
49/// How old the cache can be before [`needs_update`] returns true.
50pub const CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
51
52/// Default upstream catalog. Users can override via the
53/// `RECURSIVE_PROVIDERS_URL` env var or `recursive providers update --url`.
54/// The repo `jeffkit/recursive-providers` publishes `providers.json` here.
55pub const DEFAULT_PROVIDERS_URL: &str =
56    "https://raw.githubusercontent.com/jeffkit/recursive-providers/main/providers.json";
57
58/// Env var override for the upstream URL.
59pub const PROVIDERS_URL_ENV: &str = "RECURSIVE_PROVIDERS_URL";
60
61/// On-disk cache envelope.
62#[derive(Debug, Serialize, Deserialize)]
63pub struct ProvidersCache {
64    pub schema_version: u32,
65    /// RFC 3339 timestamp of when this cache was written.
66    pub updated_at: String,
67    /// URL this cache was fetched from.
68    pub source_url: String,
69    pub providers: Vec<ProviderPreset>,
70}
71
72/// Return the configured upstream URL: `RECURSIVE_PROVIDERS_URL` if set,
73/// otherwise [`DEFAULT_PROVIDERS_URL`].
74pub fn configured_url() -> String {
75    std::env::var(PROVIDERS_URL_ENV).unwrap_or_else(|_| DEFAULT_PROVIDERS_URL.to_string())
76}
77
78/// Return path to the cache file: `<user_data_dir>/providers_cache.json`.
79pub fn cache_path() -> PathBuf {
80    user_data_dir().join("providers_cache.json")
81}
82
83/// Load the cache from disk. Returns `None` if the file is absent or
84/// unreadable — callers fall back to bundled presets in that case.
85pub 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
94/// True if the cache is absent or older than [`CACHE_TTL`].
95pub 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
111/// Validate an upstream catalog URL. Mirrors the SSRF guard in
112/// `tools::web_fetch`: requires an `http(s)` scheme and rejects
113/// loopback / private / link-local / cloud-metadata hosts so a
114/// misconfigured `RECURSIVE_PROVIDERS_URL` cannot be turned into an
115/// internal-network probe.
116pub 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
157/// Returns true for IP addresses that must not be reached
158/// (loopback, private RFC-1918, link-local, cloud metadata, unspecified).
159/// Duplicated from `tools::web_fetch` rather than shared, because the
160/// fetch tool's helper is private and lifting it into a common util would
161/// widen this change's blast radius.
162fn 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
181/// Download the providers JSON from `url`, parse it, write to the cache
182/// file, and return the parsed [`ProvidersCache`]. The URL is validated by
183/// [`validate_providers_url`] before any request is made.
184///
185/// Async — must be called from a tokio runtime.
186pub 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
234/// Merge cached presets over bundled ones. For each preset in `cache`, the
235/// cached copy wins (placed first); bundled presets whose `id` is absent
236/// from the cache are appended afterwards. Used by
237/// [`crate::providers::all_presets_effective`].
238pub 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
251/// Spawn a best-effort background task that silently refreshes the cache if
252/// it is stale. Errors are logged at `debug` level and never surface to the
253/// caller. Returns immediately; the refresh happens concurrently. No-op if
254/// the cache is fresh.
255///
256/// Only spawned by long-running command paths in the CLI, and only when
257/// `RECURSIVE_PROVIDERS_AUTO_REFRESH=1` is set — one-shot commands must not
258/// make surprise network requests.
259pub 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        // No network: write a cache envelope directly and confirm load_cache
380        // parses it back. PinnedRecursiveHome points user_data_dir() at the
381        // temp dir on every platform (incl. Windows, where HOME/USERPROFILE
382        // pinning alone is unreliable).
383        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    // ── Additional gap-filling tests ─────────────────────────────────────────
415
416    #[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        // kills `v4.is_unspecified()` removal mutation
489        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        // kills `v4.is_link_local()` removal mutation (169.254.0.0/16)
497        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        // kills `v6.is_multicast()` removal mutation
508        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        // kills `(v6.segments()[0] & 0xfe00) == 0xfc00` removal mutation (fc00::/7 ULA)
519        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        // kills `(v6.segments()[0] & 0xffc0) == 0xfe80` removal mutation (fe80::/10)
530        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        // kills function-level replacement of `is_non_routable_ip`
541        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        // Write a fresh cache file
555        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        // File was just written — modification time is NOW → not stale
566        assert!(
567            !needs_update(),
568            "freshly written cache must not need update"
569        );
570        Ok(())
571    }
572}