Skip to main content

haste_fhir_package/
registry.rs

1//! A FHIR package registry's npm-style API, as packages.fhir.org serves it:
2//! `GET {registry}/{name}` lists a package's versions and
3//! `GET {registry}/{name}/{version}` is the version's tarball.
4//!
5//! Requests go only to the configured registry: callers name packages and
6//! never give a URL, the registry must be https, and a redirect is followed
7//! only while it stays on the registry's scheme, host and port.
8
9use crate::{PackageError, valid_name, valid_version};
10use reqwest::{Url, redirect};
11use serde::Deserialize;
12use std::{collections::BTreeMap, sync::LazyLock, time::Duration};
13
14/// The most redirects followed within the registry for one request.
15const MAX_REDIRECTS: usize = 5;
16
17static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
18    reqwest::Client::builder()
19        .connect_timeout(Duration::from_secs(10))
20        .timeout(Duration::from_mins(2))
21        .https_only(true)
22        .redirect(redirect::Policy::custom(within_registry))
23        .user_agent(concat!("haste-health/", env!("CARGO_PKG_VERSION")))
24        .build()
25        .expect("failed to build the package registry HTTP client")
26});
27
28/// Follows a redirect only to the origin the request started at.
29fn within_registry(attempt: redirect::Attempt) -> redirect::Action {
30    let start = attempt.previous().first().map(Url::origin);
31    if start.is_none_or(|start| start != attempt.url().origin()) {
32        attempt.error("the registry redirected to another host")
33    } else if attempt.previous().len() > MAX_REDIRECTS {
34        attempt.error("too many redirects")
35    } else {
36        attempt.follow()
37    }
38}
39
40/// The configured registry as a base URL. An empty setting means the server
41/// does not fetch packages; anything but an https URL with a host is refused.
42fn registry_base(registry: &str) -> Result<Url, PackageError> {
43    let registry = registry.trim();
44    if registry.is_empty() {
45        return Err(PackageError::RegistryDisabled);
46    }
47    let base = Url::parse(registry).map_err(|_| PackageError::InvalidRegistry)?;
48    if base.scheme() != "https"
49        || base.host().is_none()
50        || base.query().is_some()
51        || base.fragment().is_some()
52    {
53        return Err(PackageError::InvalidRegistry);
54    }
55    Ok(base)
56}
57
58/// What `GET {registry}/{name}` answers, as far as picking a version needs.
59#[derive(Deserialize, Default)]
60#[serde(default)]
61struct PackageVersions {
62    #[serde(rename = "dist-tags")]
63    dist_tags: BTreeMap<String, String>,
64    versions: BTreeMap<String, serde_json::Value>,
65}
66
67fn url(registry: &Url, segments: &[&str]) -> Url {
68    let mut url = registry.clone();
69    if let Ok(mut path) = url.path_segments_mut() {
70        path.pop_if_empty().extend(segments);
71    }
72    url
73}
74
75/// What went wrong, with its causes but not the request URL: a registry URL
76/// may carry credentials, and the message goes back to the caller.
77fn registry_error(error: reqwest::Error) -> PackageError {
78    let error = error.without_url();
79    let mut message = error.to_string();
80    let mut source = std::error::Error::source(&error);
81    while let Some(cause) = source {
82        message.push_str(": ");
83        message.push_str(&cause.to_string());
84        source = cause.source();
85    }
86    PackageError::Registry(message)
87}
88
89/// Whether `requested` names one version, so the registry need not be asked
90/// which versions exist.
91fn is_exact(requested: &str) -> bool {
92    requested != "latest"
93        && !requested
94            .split(['.', '-', '+'])
95            .any(|part| matches!(part, "x" | "X" | "*"))
96}
97
98/// The newest of `versions` that `requested` (`6.1.x`) matches. Releases
99/// sort by semver; a version that is not semver only matches exactly.
100fn newest_match<'a>(versions: impl Iterator<Item = &'a String>, requested: &str) -> Option<String> {
101    let prefix = requested
102        .split('.')
103        .take_while(|part| !matches!(*part, "x" | "X" | "*"))
104        .fold(String::new(), |mut prefix, part| {
105            prefix.push_str(part);
106            prefix.push('.');
107            prefix
108        });
109    versions
110        .filter(|version| version.starts_with(&prefix))
111        .filter_map(|version| {
112            semver::Version::parse(version)
113                .ok()
114                .filter(|parsed| parsed.pre.is_empty())
115                .map(|parsed| (parsed, version))
116        })
117        .max_by(|(a, _), (b, _)| a.cmp(b))
118        .map(|(_, version)| version.clone())
119}
120
121/// The version of `name` to install: `requested` when it names one exactly,
122/// else the registry's latest (no version, or `latest`) or newest match
123/// (`6.1.x`).
124///
125/// # Errors
126///
127/// When the server does not fetch packages, the name or version is malformed,
128/// the registry does not know the package or has no matching version, or
129/// cannot be reached.
130pub async fn resolve_version(
131    registry: &str,
132    name: &str,
133    requested: Option<&str>,
134) -> Result<String, PackageError> {
135    let registry = registry_base(registry)?;
136    if !valid_name(name) {
137        return Err(PackageError::InvalidName(name.to_string()));
138    }
139    if let Some(requested) = requested {
140        if !valid_version(requested) {
141            return Err(PackageError::InvalidVersion(requested.to_string()));
142        }
143        if is_exact(requested) {
144            return Ok(requested.to_string());
145        }
146    }
147
148    let response = HTTP
149        .get(url(&registry, &[name]))
150        .header("Accept", "application/json")
151        .send()
152        .await
153        .map_err(registry_error)?;
154    if response.status() == reqwest::StatusCode::NOT_FOUND {
155        return Err(PackageError::NotFound(name.to_string()));
156    }
157    let body = response
158        .error_for_status()
159        .map_err(registry_error)?
160        .bytes()
161        .await
162        .map_err(registry_error)?;
163    let versions = serde_json::from_slice::<PackageVersions>(&body)
164        .map_err(|e| PackageError::Registry(format!("unexpected response for '{name}': {e}")))?;
165
166    let requested = requested.unwrap_or("latest");
167    let resolved = if requested == "latest" {
168        versions.dist_tags.get("latest").cloned()
169    } else {
170        newest_match(versions.versions.keys(), requested)
171    };
172    resolved.ok_or_else(|| PackageError::NoMatchingVersion(name.to_string(), requested.to_string()))
173}
174
175/// Downloads `name` at `version` from `registry`, refusing a tarball larger
176/// than `max_bytes`.
177///
178/// # Errors
179///
180/// When the server does not fetch packages, the name or version is malformed,
181/// the registry does not have that version, the download is too large, or the
182/// registry cannot be reached.
183pub async fn download(
184    registry: &str,
185    name: &str,
186    version: &str,
187    max_bytes: u64,
188) -> Result<Vec<u8>, PackageError> {
189    let registry = registry_base(registry)?;
190    if !valid_name(name) {
191        return Err(PackageError::InvalidName(name.to_string()));
192    }
193    if !valid_version(version) {
194        return Err(PackageError::InvalidVersion(version.to_string()));
195    }
196
197    let mut response = HTTP
198        .get(url(&registry, &[name, version]))
199        .send()
200        .await
201        .map_err(registry_error)?;
202    if response.status() == reqwest::StatusCode::NOT_FOUND {
203        return Err(PackageError::NotFound(format!("{name}@{version}")));
204    }
205    response = response.error_for_status().map_err(registry_error)?;
206    if response
207        .content_length()
208        .is_some_and(|length| length > max_bytes)
209    {
210        return Err(PackageError::TooLarge(max_bytes));
211    }
212
213    let mut bytes = Vec::new();
214    while let Some(chunk) = response.chunk().await.map_err(registry_error)? {
215        if (bytes.len() + chunk.len()) as u64 > max_bytes {
216            return Err(PackageError::TooLarge(max_bytes));
217        }
218        bytes.extend_from_slice(&chunk);
219    }
220    Ok(bytes)
221}