Skip to main content

confium_registry/
install.rs

1//! Install + local-plugin management.
2//!
3//! [`install`] resolves a plugin against a [`crate::Client`], downloads
4//! the artifact through a pluggable [`Downloader`] (so tests can inject
5//! bytes), verifies the SHA-256, and writes the result to
6//! [`crate::paths::plugin_install_dir`]. A copy of the manifest is
7//! stashed alongside (`.manifest`) so `list`/`info`/`update` can read
8//! metadata without re-hitting the registry.
9//!
10//! Local enumeration helpers ([`list_installed`], [`read_installed`],
11//! [`remove`]) keep the file-layout knowledge in one place.
12
13use std::io::Read;
14use std::path::{Path, PathBuf};
15
16use crate::client::Client;
17use crate::error::{Error, Result};
18use crate::manifest::Manifest;
19use crate::paths::{plugin_install_dir, plugins_dir};
20
21/// Pluggable artifact transport, mirroring [`crate::client::Fetcher`]
22/// but for the binary blob referenced by a manifest's `[artifact]` url.
23///
24/// The default implementation is a no-op stub: real HTTP downloads are
25/// waiting on a network crate being wired through the workspace
26/// (`confium-net`). Tests inject a [`MemoryDownloader`].
27pub trait Downloader {
28    fn download(&self, url: &str) -> Result<Vec<u8>>;
29}
30
31/// In-memory downloader keyed by URL.
32#[derive(Default, Clone)]
33pub struct MemoryDownloader {
34    artifacts: std::collections::HashMap<String, Vec<u8>>,
35}
36
37impl MemoryDownloader {
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    pub fn with(mut self, url: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
43        self.artifacts.insert(url.into(), body.into());
44        self
45    }
46}
47
48impl Downloader for MemoryDownloader {
49    fn download(&self, url: &str) -> Result<Vec<u8>> {
50        self.artifacts
51            .get(url)
52            .cloned()
53            .ok_or_else(|| Error::Download {
54                message: format!("no artifact registered for {url}"),
55            })
56    }
57}
58
59/// A stub downloader that surfaces a typed "not yet implemented" for any
60/// URL. Used by the CLI when no real transport is wired in yet.
61pub struct NoopDownloader;
62
63impl Downloader for NoopDownloader {
64    fn download(&self, url: &str) -> Result<Vec<u8>> {
65        Err(Error::Download {
66            message: format!("network download not yet wired (confium-net pending); URL: {url}"),
67        })
68    }
69}
70
71/// HTTP downloader using `ureq`. The production downloader for CLI
72/// `install` and `update` commands.
73///
74/// Construct with [`HttpDownloader::new`] for defaults, or
75/// [`HttpDownloader::with_agent`] to customize the User-Agent.
76#[derive(Clone)]
77pub struct HttpDownloader {
78    agent: ureq::Agent,
79}
80
81impl HttpDownloader {
82    /// Build a downloader with default settings: 30-second timeout,
83    /// User-Agent `confium/<version>`.
84    pub fn new() -> Self {
85        Self::with_agent(&format!("confium/{}", env!("CARGO_PKG_VERSION")))
86    }
87
88    /// Build with a custom User-Agent string.
89    pub fn with_agent(user_agent: &str) -> Self {
90        let config = ureq::config::Config::builder()
91            .user_agent(user_agent)
92            .timeout_global(Some(std::time::Duration::from_secs(30)))
93            .build();
94        Self {
95            agent: ureq::Agent::new_with_config(config),
96        }
97    }
98}
99
100impl Default for HttpDownloader {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl Downloader for HttpDownloader {
107    fn download(&self, url: &str) -> Result<Vec<u8>> {
108        let mut response = self.agent.get(url).call().map_err(|e| Error::Download {
109            message: format!("HTTP request to {url} failed: {e}"),
110        })?;
111        // ureq 3 returns non-2xx responses as Ok; convert to an error so
112        // callers never see an error-page body as a successful download.
113        if !response.status().is_success() {
114            return Err(Error::Download {
115                message: format!("HTTP request to {url} returned {}", response.status()),
116            });
117        }
118
119        let mut bytes = Vec::with_capacity(1024 * 64);
120        response
121            .body_mut()
122            .as_reader()
123            .read_to_end(&mut bytes)
124            .map_err(|e| Error::Download {
125                message: format!("reading body from {url} failed: {e}"),
126            })?;
127
128        Ok(bytes)
129    }
130}
131
132/// The on-disk record for an installed plugin: the artifact path plus
133/// the cached manifest.
134#[derive(Debug, Clone)]
135pub struct InstalledRecord {
136    pub name: String,
137    pub version: String,
138    pub artifact_path: PathBuf,
139    pub manifest: Manifest,
140}
141
142/// Install `name` at `version` (or latest when `None`).
143///
144/// `override_home` redirects the install location for tests; pass `None`
145/// for real use. Returns the [`InstalledRecord`] for the newly installed
146/// plugin.
147pub fn install<F: crate::client::Fetcher, D: Downloader>(
148    client: &Client<F>,
149    downloader: &D,
150    override_home: Option<&PathBuf>,
151    name: &str,
152    version: Option<&str>,
153) -> Result<InstalledRecord> {
154    let manifest = client.resolve(name, version)?;
155    install_manifest(downloader, override_home, manifest)
156}
157
158/// Install a resolved manifest. Split out so `update` can reuse the path
159/// after re-resolving.
160pub fn install_manifest<D: Downloader>(
161    downloader: &D,
162    override_home: Option<&PathBuf>,
163    manifest: Manifest,
164) -> Result<InstalledRecord> {
165    let name = manifest.plugin.name.clone();
166    let version = manifest.plugin.version.clone();
167
168    let bytes = downloader.download(&manifest.artifact.url)?;
169    verify_sha256(&name, &version, &bytes, &manifest.artifact.sha256)?;
170
171    let target = plugin_install_dir(override_home, &name, &version)?;
172    ensure_parent(&target)?;
173
174    write_bytes(&target, &bytes, "artifact")?;
175
176    // Stash the manifest next to the artifact so `list`/`info`/`update`
177    // can read metadata offline.
178    let manifest_path = manifest_path(&target);
179    let manifest_toml =
180        toml::to_string_pretty(&manifest).map_err(|e| Error::TomlSerialize { source: e })?;
181    write_str(&manifest_path, &manifest_toml, "manifest")?;
182
183    Ok(InstalledRecord {
184        name,
185        version,
186        artifact_path: target,
187        manifest,
188    })
189}
190
191/// Enumerate installed plugins by scanning the plugins directory for
192/// `<name>-<version>.so` files with a sibling `.manifest`.
193pub fn list_installed(override_home: Option<&PathBuf>) -> Result<Vec<InstalledRecord>> {
194    let dir = plugins_dir(override_home)?;
195    if !dir.exists() {
196        return Ok(Vec::new());
197    }
198    let mut records = Vec::new();
199    let entries = std::fs::read_dir(&dir)
200        .map_err(|e| Error::io(e, format!("failed to read {}", dir.display())))?;
201    for entry in entries {
202        let entry = entry.map_err(|e| Error::io(e, "directory iteration error"))?;
203        let path = entry.path();
204        if path.extension().and_then(|e| e.to_str()) != Some("so") {
205            continue;
206        }
207        let manifest_path = manifest_path(&path);
208        if !manifest_path.exists() {
209            continue;
210        }
211        if let Ok(record) = read_record(&path) {
212            records.push(record);
213        }
214    }
215    records.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
216    Ok(records)
217}
218
219/// Read a previously installed plugin's record from its artifact path.
220pub fn read_installed(
221    override_home: Option<&PathBuf>,
222    name: &str,
223    version: &str,
224) -> Result<InstalledRecord> {
225    let path = plugin_install_dir(override_home, name, version)?;
226    if !path.exists() {
227        return Err(Error::NotInstalled {
228            name: name.to_string(),
229        });
230    }
231    read_record(&path)
232}
233
234/// Remove a plugin by name. Removes every installed version whose name
235/// matches (there should only be one under the single-version-per-name
236/// model, but this is forgiving).
237pub fn remove(override_home: Option<&PathBuf>, name: &str) -> Result<()> {
238    let installed = list_installed(override_home)?;
239    let mut removed = 0;
240    for record in installed {
241        if record.name != name {
242            continue;
243        }
244        let manifest_path = manifest_path(&record.artifact_path);
245        let _ = std::fs::remove_file(&manifest_path);
246        std::fs::remove_file(&record.artifact_path).map_err(|e| {
247            Error::io(
248                e,
249                format!("failed to remove {}", record.artifact_path.display()),
250            )
251        })?;
252        removed += 1;
253    }
254    if removed == 0 {
255        return Err(Error::NotInstalled {
256            name: name.to_string(),
257        });
258    }
259    Ok(())
260}
261
262fn read_record(artifact_path: &Path) -> Result<InstalledRecord> {
263    let manifest_path = manifest_path(artifact_path);
264    let body = std::fs::read_to_string(&manifest_path)
265        .map_err(|e| Error::io(e, format!("failed to read {}", manifest_path.display())))?;
266    let manifest: Manifest = toml::from_str(&body).map_err(|e| Error::TomlParse {
267        path: manifest_path.display().to_string(),
268        source: e,
269    })?;
270    let (name, version) = parse_artifact_name(artifact_path).ok_or_else(|| {
271        Error::io(
272            std::io::Error::new(std::io::ErrorKind::InvalidData, "bad artifact filename"),
273            format!(
274                "artifact filename {} is not <name>-<version>.so",
275                artifact_path.display()
276            ),
277        )
278    })?;
279    Ok(InstalledRecord {
280        name,
281        version,
282        artifact_path: artifact_path.to_path_buf(),
283        manifest,
284    })
285}
286
287fn parse_artifact_name(path: &Path) -> Option<(String, String)> {
288    let stem = path.file_stem()?.to_str()?;
289    let idx = stem.rfind('-')?;
290    let name = stem[..idx].to_string();
291    let version = stem[idx + 1..].to_string();
292    Some((name, version))
293}
294
295fn manifest_path(artifact: &Path) -> PathBuf {
296    let mut p = artifact.to_path_buf();
297    p.set_extension("manifest");
298    p
299}
300
301fn ensure_parent(path: &Path) -> Result<()> {
302    if let Some(parent) = path.parent() {
303        std::fs::create_dir_all(parent)
304            .map_err(|e| Error::io(e, format!("failed to create {}", parent.display())))?;
305    }
306    Ok(())
307}
308
309fn write_bytes(path: &Path, bytes: &[u8], what: &str) -> Result<()> {
310    std::fs::write(path, bytes)
311        .map_err(|e| Error::io(e, format!("failed to write {what} to {}", path.display())))
312}
313
314fn write_str(path: &Path, body: &str, what: &str) -> Result<()> {
315    std::fs::write(path, body)
316        .map_err(|e| Error::io(e, format!("failed to write {what} to {}", path.display())))
317}
318
319fn verify_sha256(name: &str, version: &str, bytes: &[u8], expected: &str) -> Result<()> {
320    use std::fmt::Write;
321    let digest = sha256(bytes);
322    let mut got = String::with_capacity(64);
323    for b in digest {
324        let _ = write!(&mut got, "{:02x}", b);
325    }
326    if got.eq_ignore_ascii_case(expected.trim()) {
327        return Ok(());
328    }
329    Err(Error::HashMismatch {
330        name: name.to_string(),
331        version: version.to_string(),
332        expected: expected.to_string(),
333        actual: got,
334    })
335}
336
337/// Minimal SHA-256 implementation.
338///
339/// We avoid pulling a crypto crate into `confium-registry` to keep the
340/// dependency surface minimal; SHA-256 is a few hundred lines of pure
341/// arithmetic and the implementation here is the public-domain
342/// reference algorithm (FIPS 180-4). It is only used for artifact
343/// integrity checking against a published digest — never for security
344/// primitives, which live in `confium-core`'s plugins.
345fn sha256(data: &[u8]) -> [u8; 32] {
346    const K: [u32; 64] = [
347        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
348        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
349        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
350        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
351        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
352        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
353        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
354        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
355        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
356        0xc67178f2,
357    ];
358
359    let mut h: [u32; 8] = [
360        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
361        0x5be0cd19,
362    ];
363
364    let bit_len = (data.len() as u64).wrapping_mul(8);
365    let mut padded = data.to_vec();
366    padded.push(0x80);
367    while padded.len() % 64 != 56 {
368        padded.push(0);
369    }
370    padded.extend_from_slice(&bit_len.to_be_bytes());
371
372    for chunk in padded.chunks_exact(64) {
373        let mut w = [0u32; 64];
374        for (i, word) in chunk.chunks_exact(4).enumerate() {
375            w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]);
376        }
377        for i in 16..64 {
378            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
379            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
380            w[i] = w[i - 16]
381                .wrapping_add(s0)
382                .wrapping_add(w[i - 7])
383                .wrapping_add(s1);
384        }
385        let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
386            (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
387        for i in 0..64 {
388            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
389            let ch = (e & f) ^ ((!e) & g);
390            let t1 = hh
391                .wrapping_add(s1)
392                .wrapping_add(ch)
393                .wrapping_add(K[i])
394                .wrapping_add(w[i]);
395            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
396            let maj = (a & b) ^ (a & c) ^ (b & c);
397            let t2 = s0.wrapping_add(maj);
398            hh = g;
399            g = f;
400            f = e;
401            e = d.wrapping_add(t1);
402            d = c;
403            c = b;
404            b = a;
405            a = t1.wrapping_add(t2);
406        }
407        h[0] = h[0].wrapping_add(a);
408        h[1] = h[1].wrapping_add(b);
409        h[2] = h[2].wrapping_add(c);
410        h[3] = h[3].wrapping_add(d);
411        h[4] = h[4].wrapping_add(e);
412        h[5] = h[5].wrapping_add(f);
413        h[6] = h[6].wrapping_add(g);
414        h[7] = h[7].wrapping_add(hh);
415    }
416
417    let mut out = [0u8; 32];
418    for (i, word) in h.iter().enumerate() {
419        out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
420    }
421    out
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use std::path::PathBuf;
428
429    fn empty_body_hash() -> String {
430        // SHA-256 of b""
431        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string()
432    }
433
434    fn manifest_for(name: &str, version: &str, sha: &str) -> Manifest {
435        let manifest_toml = format!(
436            r#"
437[plugin]
438name = "{name}"
439version = "{version}"
440publisher = "ribose"
441
442[artifact]
443url = "https://example.test/{name}.so"
444size = 0
445sha256 = "{sha}"
446"#
447        );
448        toml::from_str(&manifest_toml).unwrap()
449    }
450
451    fn hex(bytes: &[u8]) -> String {
452        let mut s = String::with_capacity(bytes.len() * 2);
453        use std::fmt::Write;
454        for b in bytes {
455            let _ = write!(&mut s, "{:02x}", b);
456        }
457        s
458    }
459
460    #[test]
461    fn sha256_of_empty_matches_known_digest() {
462        assert_eq!(
463            hex(&sha256(b"")),
464            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
465        );
466    }
467
468    #[test]
469    fn sha256_of_abc() {
470        assert_eq!(
471            hex(&sha256(b"abc")),
472            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
473        );
474    }
475
476    #[test]
477    fn install_writes_artifact_and_manifest() {
478        let tmp = tempfile::tempdir().unwrap();
479        let home = PathBuf::from(tmp.path());
480        let manifest = manifest_for("botan", "3.2.0", &empty_body_hash());
481        let downloader = MemoryDownloader::new().with("https://example.test/botan.so", Vec::new());
482
483        let record = install_manifest(&downloader, Some(&home), manifest).unwrap();
484        assert!(record.artifact_path.exists());
485        assert!(manifest_path(&record.artifact_path).exists());
486
487        let installed = list_installed(Some(&home)).unwrap();
488        assert_eq!(installed.len(), 1);
489        assert_eq!(installed[0].name, "botan");
490        assert_eq!(installed[0].version, "3.2.0");
491    }
492
493    #[test]
494    fn install_rejects_hash_mismatch() {
495        let tmp = tempfile::tempdir().unwrap();
496        let home = PathBuf::from(tmp.path());
497        let manifest = manifest_for("botan", "3.2.0", "deadbeef");
498        let downloader =
499            MemoryDownloader::new().with("https://example.test/botan.so", vec![1, 2, 3]);
500        let err = install_manifest(&downloader, Some(&home), manifest).unwrap_err();
501        assert!(matches!(err, Error::HashMismatch { .. }));
502    }
503
504    #[test]
505    fn remove_deletes_artifact_and_manifest() {
506        let tmp = tempfile::tempdir().unwrap();
507        let home = PathBuf::from(tmp.path());
508        let manifest = manifest_for("botan", "3.2.0", &empty_body_hash());
509        let downloader = MemoryDownloader::new().with("https://example.test/botan.so", Vec::new());
510        install_manifest(&downloader, Some(&home), manifest).unwrap();
511
512        remove(Some(&home), "botan").unwrap();
513        assert!(list_installed(Some(&home)).unwrap().is_empty());
514    }
515
516    #[test]
517    fn remove_unknown_errors() {
518        let tmp = tempfile::tempdir().unwrap();
519        let home = PathBuf::from(tmp.path());
520        let err = remove(Some(&home), "ghost").unwrap_err();
521        assert!(matches!(err, Error::NotInstalled { .. }));
522    }
523
524    #[test]
525    fn list_on_missing_dir_is_empty() {
526        let tmp = tempfile::tempdir().unwrap();
527        let home = PathBuf::from(tmp.path());
528        let installed = list_installed(Some(&home)).unwrap();
529        assert!(installed.is_empty());
530    }
531
532    #[test]
533    fn noop_downloader_errors() {
534        let err = NoopDownloader
535            .download("https://example.test/x.so")
536            .unwrap_err();
537        assert!(matches!(err, Error::Download { .. }));
538    }
539}