Skip to main content

helios_sof/
remote_fetch.rs

1//! Remote reference prefetch (Phases 4–5, plus streaming support).
2//!
3//! This is the only part of remote `resolve()` that performs I/O. It runs at the
4//! async edge (CLI / server), *before* the synchronous, rayon-parallel row
5//! generation, and returns the fetched resources so they can be folded into the
6//! in-memory resolution pool (see `build_resolution_scope` in `lib.rs`). The
7//! evaluation core therefore stays pure and the run remains reproducible from
8//! `(bundle + fetched snapshot)`.
9//!
10//! [`RemoteResolver`] holds the config plus a bounded, cross-call cache and a
11//! fetch counter, so it serves a whole *run*:
12//! - **single Bundle** — one [`RemoteResolver::resolve`] call over all bundle
13//!   references ([`prefetch_external_resources`]);
14//! - **streaming / NDJSON** — one resolver shared across every chunk, so a
15//!   reference recurring across chunks is fetched once and `max_fetches` is a
16//!   per-stream cap. The cache is a bounded LRU (with negative caching) so
17//!   streaming memory stays bounded.
18//!
19//! Per resolve call:
20//! 1. Keep only references the allowlist permits and that do not already resolve
21//!    locally — [`RemoteResolveConfig::fetch_decision`] / `resolves_in_bundle`.
22//! 2. Serve cache hits; for misses, validate each host (literal IPs were
23//!    explicitly allowlisted; hostnames are resolved via DNS and **pinned** to
24//!    addresses that pass [`is_blocked_address`] — enforcing the SSRF guard and
25//!    defeating DNS rebinding), then fetch concurrently under the per-run cap,
26//!    timeout, size cap, and optional per-host bearer auth.
27//! 3. Cache results (success and miss), follow chained references up to
28//!    `max_depth`, and parse the collected JSON into [`FhirResource`]s of the
29//!    bundle's version (skipping anything that fails to parse — non-fatal).
30
31use std::collections::{BTreeSet, HashSet};
32use std::num::NonZeroUsize;
33use std::sync::Mutex;
34use std::sync::atomic::{AtomicUsize, Ordering};
35
36use futures::stream::{self, StreamExt};
37use lru::LruCache;
38use serde_json::Value;
39use tokio::net::lookup_host;
40use url::{Host, Url};
41
42use helios_fhir::{FhirResource, FhirVersion};
43
44use crate::reference_collector::{
45    collect_reference_strings, collect_references_from_json, collect_resource_keys,
46    resolves_in_bundle,
47};
48use crate::remote_resolver::{RemoteResolveConfig, is_blocked_address};
49use crate::{SofBundle, parse_json_to_fhir_resource_pub};
50
51/// A reusable remote-resolution engine for one run (a Bundle or an NDJSON stream).
52///
53/// Holds the [`RemoteResolveConfig`], a bounded LRU cache of fetched resource JSON
54/// (with negative caching of misses), and a fetch counter. Sharing one instance
55/// across a stream's chunks means a reference seen in many chunks is fetched once,
56/// and `max_fetches` is enforced across the whole run.
57pub struct RemoteResolver {
58    config: RemoteResolveConfig,
59    /// Reference string → fetched resource JSON, or `None` for a known miss.
60    cache: Mutex<LruCache<String, Option<Value>>>,
61    /// Total fetches performed this run (capped at `config.max_fetches`).
62    fetched: AtomicUsize,
63}
64
65impl RemoteResolver {
66    /// Creates a resolver with an empty cache sized to `config.cache_max_entries`.
67    pub fn new(config: RemoteResolveConfig) -> Self {
68        let cap = NonZeroUsize::new(config.cache_max_entries.max(1)).expect("cap >= 1");
69        Self {
70            config,
71            cache: Mutex::new(LruCache::new(cap)),
72            fetched: AtomicUsize::new(0),
73        }
74    }
75
76    /// Resolves `seed_refs` — and, within `max_depth`, references discovered inside
77    /// fetched resources — returning the parsed external resources to fold into a
78    /// resolution pool. References already resolvable in `local_keys` are skipped.
79    pub async fn resolve(
80        &self,
81        seed_refs: Vec<String>,
82        local_keys: &BTreeSet<String>,
83        version: FhirVersion,
84    ) -> Vec<FhirResource> {
85        if !self.config.is_active() {
86            return Vec::new();
87        }
88
89        let mut resolved_json: Vec<Value> = Vec::new();
90        let mut seen: HashSet<String> = HashSet::new();
91        let mut frontier: Vec<String> = seed_refs
92            .into_iter()
93            .filter(|r| self.config.fetch_decision(r).is_allowed())
94            .filter(|r| !resolves_in_bundle(r, local_keys))
95            .filter(|r| seen.insert(r.clone()))
96            .collect();
97
98        let mut depth = 0;
99        while depth < self.config.max_depth && !frontier.is_empty() {
100            depth += 1;
101
102            // Partition this round into cache hits and misses.
103            let mut round_json: Vec<Value> = Vec::new();
104            let mut misses: Vec<String> = Vec::new();
105            {
106                let mut cache = self.cache.lock().unwrap();
107                for reference in frontier.drain(..) {
108                    match cache.get(&reference) {
109                        Some(Some(json)) => round_json.push(json.clone()),
110                        Some(None) => {} // known miss — skip
111                        None => misses.push(reference),
112                    }
113                }
114            }
115
116            // Fetch the misses, then record results (success or miss) in the cache.
117            if !misses.is_empty() {
118                let fetched = self.fetch_batch(misses).await;
119                let mut cache = self.cache.lock().unwrap();
120                for (reference, result) in fetched {
121                    if let Some(json) = &result {
122                        round_json.push(json.clone());
123                    }
124                    cache.put(reference, result);
125                }
126            }
127
128            // Queue chained references discovered in fetched resources.
129            if depth < self.config.max_depth {
130                for json in &round_json {
131                    for reference in collect_references_from_json(json) {
132                        if self.config.fetch_decision(&reference).is_allowed()
133                            && !resolves_in_bundle(&reference, local_keys)
134                            && seen.insert(reference.clone())
135                        {
136                            frontier.push(reference);
137                        }
138                    }
139                }
140            }
141
142            resolved_json.extend(round_json);
143        }
144
145        resolved_json
146            .into_iter()
147            .filter_map(
148                |json| match parse_json_to_fhir_resource_pub(json, version) {
149                    Ok(resource) => Some(resource),
150                    Err(err) => {
151                        tracing::warn!(error = %err, "remote resolve: skipping unparseable resource");
152                        None
153                    }
154                },
155            )
156            .collect()
157    }
158
159    /// Fetches a batch of (uncached) references concurrently, honouring the per-run
160    /// fetch cap, host validation, timeout and size limits. Returns
161    /// `(reference, Some(json))` on success and `(reference, None)` on any failure
162    /// or cap exhaustion, so the caller can (negative-)cache every outcome.
163    async fn fetch_batch(&self, references: Vec<String>) -> Vec<(String, Option<Value>)> {
164        let already = self.fetched.load(Ordering::Relaxed);
165        let remaining = self.config.max_fetches.saturating_sub(already);
166        if remaining == 0 {
167            tracing::warn!(
168                max_fetches = self.config.max_fetches,
169                "remote resolve: fetch cap reached; skipping further fetches"
170            );
171            return references.into_iter().map(|r| (r, None)).collect();
172        }
173
174        let (to_fetch, skipped): (Vec<String>, Vec<String>) = if references.len() > remaining {
175            tracing::warn!(
176                skipped = references.len() - remaining,
177                max_fetches = self.config.max_fetches,
178                "remote resolve: fetch cap reached; some references not fetched"
179            );
180            let mut iter = references.into_iter();
181            let take: Vec<String> = iter.by_ref().take(remaining).collect();
182            (take, iter.collect())
183        } else {
184            (references, Vec::new())
185        };
186        self.fetched.fetch_add(to_fetch.len(), Ordering::Relaxed);
187
188        let (client, allowed_hosts) = build_validated_client(&to_fetch, &self.config).await;
189        let config = &self.config;
190        let allowed = &allowed_hosts;
191
192        let mut results: Vec<(String, Option<Value>)> = stream::iter(to_fetch)
193            .map(|reference| {
194                let client = client.clone();
195                async move {
196                    let json = if host_is_allowed(&reference, allowed) {
197                        fetch_one(&client, &reference, config).await
198                    } else {
199                        None
200                    };
201                    (reference, json)
202                }
203            })
204            .buffer_unordered(config.concurrency)
205            .collect()
206            .await;
207
208        // Negative-cache the cap-skipped references too (not retried this run).
209        results.extend(skipped.into_iter().map(|r| (r, None)));
210        results
211    }
212}
213
214/// Fetches the allowlisted external references reachable from `bundle` and returns
215/// them as parsed resources to merge into the resolution pool (single-Bundle path).
216///
217/// Returns an empty vector when remote resolution is inactive. Never errors: every
218/// failure mode (disallowed host, timeout, non-2xx, oversize, unparseable) is
219/// logged and skipped, leaving the reference to fall back to the in-scope
220/// typed-stub/empty behaviour.
221pub async fn prefetch_external_resources(
222    bundle: &SofBundle,
223    config: &RemoteResolveConfig,
224) -> Vec<FhirResource> {
225    if !config.is_active() {
226        return Vec::new();
227    }
228    let resolver = RemoteResolver::new(config.clone());
229    let refs = collect_reference_strings(bundle);
230    let keys = collect_resource_keys(bundle);
231    let fetched = resolver.resolve(refs, &keys, bundle.version()).await;
232    if !fetched.is_empty() {
233        tracing::info!(
234            count = fetched.len(),
235            "remote resolve: prefetched resources"
236        );
237    }
238    fetched
239}
240
241/// Builds an HTTP client whose DNS is pinned to SSRF-validated addresses, and
242/// returns the set of (lowercased) hosts that are cleared to fetch.
243///
244/// Literal-IP hosts are cleared as-is (they could only reach here by being
245/// explicitly allowlisted). Hostnames are resolved once; if every resolved
246/// address passes [`is_blocked_address`] the host is pinned to those addresses,
247/// otherwise it is dropped.
248async fn build_validated_client(
249    batch: &[String],
250    config: &RemoteResolveConfig,
251) -> (reqwest::Client, HashSet<String>) {
252    let mut builder = reqwest::Client::builder()
253        .timeout(config.timeout)
254        .redirect(reqwest::redirect::Policy::none())
255        .user_agent(concat!("helios-sof/", env!("CARGO_PKG_VERSION")));
256
257    let mut allowed_hosts: HashSet<String> = HashSet::new();
258    let mut processed: HashSet<String> = HashSet::new();
259
260    for reference in batch {
261        let Ok(url) = Url::parse(reference) else {
262            continue;
263        };
264        let (Some(host), Some(host_str)) = (url.host(), url.host_str()) else {
265            continue;
266        };
267        let host_key = host_str.to_ascii_lowercase();
268        if !processed.insert(host_key.clone()) {
269            continue;
270        }
271        let port = url.port_or_known_default().unwrap_or(443);
272
273        match host {
274            // Explicitly-allowlisted literal IPs are the operator's own decision.
275            Host::Ipv4(_) | Host::Ipv6(_) => {
276                allowed_hosts.insert(host_key);
277            }
278            Host::Domain(name) => match lookup_host((name, port)).await {
279                Ok(addrs) => {
280                    let addrs: Vec<std::net::SocketAddr> = addrs.collect();
281                    if addrs.is_empty() {
282                        tracing::warn!(host = name, "remote resolve: host did not resolve");
283                    } else if addrs
284                        .iter()
285                        .any(|addr| is_blocked_address(addr.ip(), config.allow_private_addresses))
286                    {
287                        tracing::warn!(
288                            host = name,
289                            "remote resolve: host resolves to a disallowed address; blocked"
290                        );
291                    } else {
292                        builder = builder.resolve_to_addrs(name, &addrs);
293                        allowed_hosts.insert(host_key);
294                    }
295                }
296                Err(err) => {
297                    tracing::warn!(host = name, error = %err, "remote resolve: DNS lookup failed")
298                }
299            },
300        }
301    }
302
303    let client = builder.build().unwrap_or_else(|err| {
304        tracing::warn!(error = %err, "remote resolve: client build failed; using default client");
305        reqwest::Client::new()
306    });
307    (client, allowed_hosts)
308}
309
310fn host_is_allowed(reference: &str, allowed_hosts: &HashSet<String>) -> bool {
311    Url::parse(reference)
312        .ok()
313        .and_then(|url| url.host_str().map(|h| h.to_ascii_lowercase()))
314        .map(|host| allowed_hosts.contains(&host))
315        .unwrap_or(false)
316}
317
318/// Fetches a single reference, enforcing auth, status, and size limits.
319async fn fetch_one(
320    client: &reqwest::Client,
321    reference: &str,
322    config: &RemoteResolveConfig,
323) -> Option<Value> {
324    let url = Url::parse(reference).ok()?;
325    let host = url.host_str()?.to_ascii_lowercase();
326
327    let mut request = client
328        .get(url.clone())
329        .header(reqwest::header::ACCEPT, "application/fhir+json");
330    if let Some(token) = config.bearer_for_host(&host) {
331        request = request.bearer_auth(token);
332    }
333
334    let response = match request.send().await {
335        Ok(resp) => resp,
336        Err(err) => {
337            tracing::debug!(reference, error = %err, "remote resolve: request failed");
338            return None;
339        }
340    };
341
342    if !response.status().is_success() {
343        tracing::debug!(reference, status = %response.status(), "remote resolve: non-success");
344        return None;
345    }
346
347    if response
348        .content_length()
349        .is_some_and(|len| len as usize > config.max_response_bytes)
350    {
351        tracing::warn!(
352            reference,
353            "remote resolve: response exceeds size cap (Content-Length)"
354        );
355        return None;
356    }
357
358    let bytes = response.bytes().await.ok()?;
359    if bytes.len() > config.max_response_bytes {
360        tracing::warn!(
361            reference,
362            len = bytes.len(),
363            "remote resolve: response exceeds size cap"
364        );
365        return None;
366    }
367
368    serde_json::from_slice(&bytes).ok()
369}