helios_sof/
remote_fetch.rs1use 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
51pub struct RemoteResolver {
58 config: RemoteResolveConfig,
59 cache: Mutex<LruCache<String, Option<Value>>>,
61 fetched: AtomicUsize,
63}
64
65impl RemoteResolver {
66 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 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 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) => {} None => misses.push(reference),
112 }
113 }
114 }
115
116 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 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 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 results.extend(skipped.into_iter().map(|r| (r, None)));
210 results
211 }
212}
213
214pub 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
241async 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 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
318async 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}