Skip to main content

harmont_cli/plugin/
install.rs

1//! Implementation of `hm plugin install <source> --pin <sha256>`.
2
3use std::path::PathBuf;
4
5use anyhow::{Context, Result, bail};
6use sha2::{Digest, Sha256};
7
8use super::host::LoadedPlugin;
9use super::paths;
10
11/// Install a plugin from a file path or HTTPS URL.
12///
13/// For HTTPS URLs, `--pin <sha256>` is required. The pin must equal
14/// the SHA-256 of the downloaded bytes (hex, lowercase).
15///
16/// On success, the plugin is written to
17/// `<user-plugins-dir>/<manifest-name>.wasm`.
18///
19/// # Errors
20///
21/// Returns an error if the source cannot be fetched, the pin does not
22/// verify, the plugin manifest fails validation, or the install dir
23/// cannot be written to.
24pub async fn install(source: &str, pin: Option<&str>) -> Result<PathBuf> {
25    let bytes = if source.starts_with("https://") {
26        let pin = pin.context("--pin <sha256> is required for HTTPS sources")?;
27        let body = reqwest::get(source)
28            .await
29            .with_context(|| format!("GET {source}"))?
30            .error_for_status()?
31            .bytes()
32            .await
33            .context("read response body")?;
34        verify_pin(&body, pin)?;
35        body.to_vec()
36    } else if source.starts_with("http://") {
37        bail!("plain http:// is not allowed; use https:// or a local file path");
38    } else {
39        let path = PathBuf::from(source);
40        if !path.is_file() {
41            bail!("no file at {}", path.display());
42        }
43        let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
44        if let Some(pin) = pin {
45            verify_pin(&bytes, pin)?;
46        }
47        bytes
48    };
49
50    // Load the plugin to extract its manifest name (used as the
51    // installed filename). Any plugin that fails validation here is
52    // not installed.
53    let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice());
54    let plugin =
55        LoadedPlugin::from_bytes(leaked, 1).context("validate plugin before installing")?;
56    let install_dir = paths::install_dir().context("resolve install dir")?;
57    std::fs::create_dir_all(&install_dir)
58        .with_context(|| format!("create {}", install_dir.display()))?;
59    let target = install_dir.join(format!("{}.wasm", plugin.manifest.name));
60    std::fs::write(&target, &bytes).with_context(|| format!("write {}", target.display()))?;
61    Ok(target)
62}
63
64fn verify_pin(bytes: &[u8], expected_hex: &str) -> Result<()> {
65    let mut h = Sha256::new();
66    h.update(bytes);
67    let got = h.finalize();
68    let got_hex = hex::encode(got);
69    if !got_hex.eq_ignore_ascii_case(expected_hex.trim()) {
70        bail!(
71            "SHA-256 mismatch: expected {expected_hex}, downloaded {got_hex}\n\
72             fix: re-fetch the source or correct the --pin value"
73        );
74    }
75    Ok(())
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use sha2::{Digest, Sha256};
82
83    #[test]
84    fn pin_verification_round_trip() {
85        let body = b"hello plugin";
86        let mut h = Sha256::new();
87        h.update(body);
88        let hex_digest = hex::encode(h.finalize());
89        assert!(verify_pin(body, &hex_digest).is_ok());
90        assert!(verify_pin(body, "deadbeef").is_err());
91    }
92}