Skip to main content

confium_registry/
client.rs

1//! Registry client.
2//!
3//! The [`Client`] resolves plugin metadata from the static-site catalog.
4//! Network access is abstracted behind the [`Fetcher`] trait so:
5//!
6//! - production builds can plug in `reqwest`/`ureq` (or read from a
7//!   mirrored checkout), and
8//! - tests inject a [`MemoryFetcher`] holding the TOML documents
9//!   verbatim, with no network.
10//!
11//! The client only reads structured TOML; the actual artifact download
12//! (the `[artifact]` URL in a manifest) is handled by [`crate::install`].
13
14use std::collections::HashMap;
15
16use crate::error::{Error, Result};
17use crate::manifest::{Manifest, PluginIndex, RegistryIndex, TrustRootsFile};
18
19/// Default path components inside the registry site.
20const INDEX_PATH: &str = "/index.toml";
21const TRUST_ROOTS_PATH: &str = "/trust-roots.toml";
22
23/// Pluggable transport for registry content.
24///
25/// Implementations return the raw bytes for a given registry-relative
26/// path (e.g. `/plugins/botan/index.toml`). The default production
27/// implementation will join these against the registry base URL and
28/// perform an HTTP GET; tests use [`MemoryFetcher`].
29pub trait Fetcher {
30    fn fetch(&self, path: &str) -> Result<Vec<u8>>;
31}
32
33/// An in-memory [`Fetcher`] keyed by registry-relative path.
34///
35/// Built via [`MemoryFetcher::new`] with the registry root URL, then
36/// populated with [`MemoryFetcher::with`]. Missing paths surface as
37/// [`Error::NotFound`].
38#[derive(Default, Clone)]
39pub struct MemoryFetcher {
40    base_url: String,
41    docs: HashMap<String, Vec<u8>>,
42}
43
44impl MemoryFetcher {
45    /// Create an empty fetcher bound to `base_url` (only used for display).
46    pub fn new(base_url: impl Into<String>) -> Self {
47        MemoryFetcher {
48            base_url: base_url.into(),
49            docs: HashMap::new(),
50        }
51    }
52
53    /// Return the base URL this fetcher is anchored to.
54    pub fn base_url(&self) -> &str {
55        &self.base_url
56    }
57
58    /// Insert a document at `path` (registry-relative, e.g.
59    /// `/plugins/botan/index.toml`).
60    pub fn with(mut self, path: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
61        self.docs.insert(path.into(), body.into());
62        self
63    }
64}
65
66impl Fetcher for MemoryFetcher {
67    fn fetch(&self, path: &str) -> Result<Vec<u8>> {
68        self.docs.get(path).cloned().ok_or_else(|| Error::NotFound {
69            path: path.to_string(),
70        })
71    }
72}
73
74/// A client bound to a registry base URL and a [`Fetcher`].
75///
76/// Construct with [`Client::new`] (default fetcher is a `MemoryFetcher`,
77/// intended for tests — production code supplies a real HTTP fetcher).
78pub struct Client<F: Fetcher = MemoryFetcher> {
79    base_url: String,
80    fetcher: F,
81}
82
83impl Client<MemoryFetcher> {
84    /// Build a client backed by a [`MemoryFetcher`]. Production code
85    /// should use [`Client::with_fetcher`] to plug in a network-capable
86    /// transport.
87    pub fn new(base_url: impl Into<String>) -> Self {
88        let base_url = base_url.into();
89        Client {
90            base_url: base_url.clone(),
91            fetcher: MemoryFetcher::new(base_url),
92        }
93    }
94}
95
96impl<F: Fetcher> Client<F> {
97    /// Build a client with a custom fetcher.
98    pub fn with_fetcher(base_url: impl Into<String>, fetcher: F) -> Self {
99        Client {
100            base_url: base_url.into(),
101            fetcher,
102        }
103    }
104
105    /// Borrow the base URL.
106    pub fn base_url(&self) -> &str {
107        &self.base_url
108    }
109
110    /// Fetch the master catalog.
111    pub fn index(&self) -> Result<RegistryIndex> {
112        let body = self.fetch(INDEX_PATH)?;
113        self.parse(INDEX_PATH, &body)
114    }
115
116    /// Fetch the default trust roots file.
117    pub fn trust_roots(&self) -> Result<TrustRootsFile> {
118        let body = self.fetch(TRUST_ROOTS_PATH)?;
119        self.parse(TRUST_ROOTS_PATH, &body)
120    }
121
122    /// Fetch a per-plugin version index.
123    ///
124    /// `versions_url` is the registry-relative path from the master
125    /// index entry (e.g. `/plugins/botan/index.toml`).
126    pub fn plugin_index(&self, versions_url: &str) -> Result<PluginIndex> {
127        let body = self.fetch(versions_url)?;
128        self.parse(versions_url, &body)
129    }
130
131    /// Fetch a per-version manifest.
132    pub fn manifest(&self, manifest_url: &str) -> Result<Manifest> {
133        let body = self.fetch(manifest_url)?;
134        self.parse(manifest_url, &body)
135    }
136
137    /// Resolve a `(name, version)` to a manifest.
138    ///
139    /// When `version` is `None`, the per-plugin `latest` pointer is used.
140    pub fn resolve(&self, name: &str, version: Option<&str>) -> Result<Manifest> {
141        let index = self.index()?;
142        let entry = index
143            .plugins
144            .iter()
145            .find(|p| p.name == name)
146            .ok_or_else(|| Error::PluginNotFound {
147                name: name.to_string(),
148            })?;
149        let plugin_index = self.plugin_index(&entry.versions_url)?;
150        let target_version = match version {
151            Some(v) => v.to_string(),
152            None => plugin_index.latest.clone(),
153        };
154        let version_entry = plugin_index
155            .versions
156            .iter()
157            .find(|v| v.version == target_version)
158            .ok_or_else(|| Error::VersionNotFound {
159                name: name.to_string(),
160                version: target_version.clone(),
161            })?;
162        self.manifest(&version_entry.manifest_url)
163    }
164
165    /// Fetch raw bytes for `path` via the configured [`Fetcher`].
166    fn fetch(&self, path: &str) -> Result<Vec<u8>> {
167        self.fetcher.fetch(path)
168    }
169
170    fn parse<T>(&self, path: &str, body: &[u8]) -> Result<T>
171    where
172        T: serde::de::DeserializeOwned,
173    {
174        let src = std::str::from_utf8(body).map_err(|e| Error::Fetch {
175            path: path.to_string(),
176            message: format!("invalid UTF-8: {e}"),
177        })?;
178        toml::from_str(src).map_err(|e| Error::TomlParse {
179            path: path.to_string(),
180            source: e,
181        })
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn sample_fetcher() -> MemoryFetcher {
190        MemoryFetcher::new("https://example.test")
191            .with(
192                INDEX_PATH,
193                r#"
194[[plugin]]
195name = "botan"
196latest = "3.2.0"
197description = "Botan"
198publishers = ["ribose"]
199versions-url = "/plugins/botan/index.toml"
200"#,
201            )
202            .with(
203                "/plugins/botan/index.toml",
204                r#"
205name = "botan"
206latest = "3.2.0"
207description = "Botan"
208
209[[version]]
210version = "3.2.0"
211manifest-url = "/plugins/botan/3.2.0/manifest.toml"
212"#,
213            )
214            .with(
215                "/plugins/botan/3.2.0/manifest.toml",
216                r#"
217[plugin]
218name = "botan"
219version = "3.2.0"
220publisher = "ribose"
221
222[artifact]
223url = "https://example.test/x.so"
224size = 10
225sha256 = "abcd"
226"#,
227            )
228    }
229
230    #[test]
231    fn client_resolves_latest() {
232        let client = Client::with_fetcher("https://example.test", sample_fetcher());
233        let manifest = client.resolve("botan", None).expect("resolve");
234        assert_eq!(manifest.plugin.name, "botan");
235        assert_eq!(manifest.plugin.version, "3.2.0");
236    }
237
238    #[test]
239    fn client_resolves_pinned_version() {
240        let client = Client::with_fetcher("https://example.test", sample_fetcher());
241        let manifest = client
242            .resolve("botan", Some("3.2.0"))
243            .expect("resolve pinned");
244        assert_eq!(manifest.plugin.version, "3.2.0");
245    }
246
247    #[test]
248    fn missing_plugin_errors() {
249        let client = Client::with_fetcher("https://example.test", sample_fetcher());
250        let err = client.resolve("ghost", None).unwrap_err();
251        assert!(matches!(
252            err,
253            Error::PluginNotFound { ref name } if name == "ghost"
254        ));
255    }
256
257    #[test]
258    fn missing_version_errors() {
259        let client = Client::with_fetcher("https://example.test", sample_fetcher());
260        let err = client.resolve("botan", Some("9.9.9")).unwrap_err();
261        assert!(matches!(
262            err,
263            Error::VersionNotFound { ref name, ref version }
264                if name == "botan" && version == "9.9.9"
265        ));
266    }
267}