use crate::{Error, Result, platform};
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::{Path, PathBuf};
pub const RELEASE_BASE_URL: &str = "https://github.com/plushie-ui/plushie-rust/releases/download";
#[derive(Debug)]
pub struct DownloadTarget {
pub binary_path: PathBuf,
pub sha256_path: PathBuf,
pub binary_url: String,
pub sha256_url: String,
}
impl DownloadTarget {
#[must_use]
pub fn new(target_dir: &Path, version: &str) -> Self {
let download_name = platform::download_name();
let bin_dir = target_dir.join("plushie/bin");
let binary_path = bin_dir.join(&download_name);
let sha256_path = bin_dir.join(format!("{download_name}.sha256"));
let binary_url = format!("{RELEASE_BASE_URL}/v{version}/{download_name}");
let sha256_url = format!("{binary_url}.sha256");
Self {
binary_path,
sha256_path,
binary_url,
sha256_url,
}
}
}
pub fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
let response = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("GET {url} failed: {e}"))?;
let mut reader = response.into_reader();
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
Ok(bytes)
}
pub fn verify_sha256(binary: &[u8], expected_sidecar: &str) -> Result<()> {
let expected_hex = expected_sidecar
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("sha256 sidecar is empty"))?
.to_ascii_lowercase();
let mut hasher = Sha256::new();
hasher.update(binary);
let actual_hex = format!("{:x}", hasher.finalize());
if actual_hex != expected_hex {
return Err(Error::Other(anyhow::anyhow!(
"sha256 mismatch: expected {expected_hex}, got {actual_hex}"
)));
}
Ok(())
}
pub fn install_binary(target: &DownloadTarget, bytes: &[u8], sidecar: &str) -> Result<()> {
if let Some(parent) = target.binary_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target.binary_path, bytes)?;
std::fs::write(&target.sha256_path, sidecar)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&target.binary_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&target.binary_path, perms)?;
}
Ok(())
}
pub fn refuse_if_native_widgets(widgets: &[crate::WidgetMetadata]) -> Result<()> {
if !widgets.is_empty() {
let names: Vec<String> = widgets.iter().map(|w| w.crate_name.clone()).collect();
return Err(Error::DownloadWithNativeWidgets {
widgets: names.join(", "),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_resolves_paths_and_urls() {
let target = DownloadTarget::new(Path::new("/project/target"), "0.6.1");
assert!(target.binary_url.contains("v0.6.1"));
assert!(target.sha256_url.ends_with(".sha256"));
assert!(
target
.binary_path
.starts_with("/project/target/plushie/bin")
);
}
#[test]
fn verifies_matching_sha256() {
let bytes = b"hello";
let mut hasher = Sha256::new();
hasher.update(bytes);
let hex = format!("{:x}", hasher.finalize());
let sidecar = format!("{hex} bin\n");
assert!(verify_sha256(bytes, &sidecar).is_ok());
}
#[test]
fn rejects_mismatching_sha256() {
let err = verify_sha256(b"hello", "deadbeef bin\n").unwrap_err();
assert!(matches!(err, Error::Other(_)));
}
#[test]
fn refuses_download_with_widgets() {
let widgets = vec![crate::WidgetMetadata {
crate_name: "my-gauge".to_string(),
crate_path: PathBuf::new(),
type_name: "my_gauge".to_string(),
constructor: "x::y()".to_string(),
}];
let err = refuse_if_native_widgets(&widgets).unwrap_err();
assert!(matches!(err, Error::DownloadWithNativeWidgets { .. }));
}
#[test]
fn allows_download_without_widgets() {
assert!(refuse_if_native_widgets(&[]).is_ok());
}
}