use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn is_command_available(name: &str) -> bool {
Command::new(name)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[derive(Debug, Clone)]
pub enum InstallationMethod {
Homebrew {
path: PathBuf,
},
Cargo {
path: PathBuf,
},
Direct {
path: PathBuf,
},
Unknown,
}
pub fn detect_installation() -> InstallationMethod {
let exe_path = match std::env::current_exe() {
Ok(path) => path,
Err(_) => return InstallationMethod::Unknown,
};
if let Ok(homebrew_prefix) = std::env::var("HOMEBREW_PREFIX") {
let homebrew_bin = PathBuf::from(homebrew_prefix)
.join("bin")
.join("research-master");
if exe_path == homebrew_bin
|| exe_path.starts_with(homebrew_bin.parent().unwrap_or(&homebrew_bin))
{
return InstallationMethod::Homebrew { path: exe_path };
}
}
if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
let cargo_bin = PathBuf::from(cargo_home)
.join("bin")
.join("research-master");
if exe_path == cargo_bin {
return InstallationMethod::Cargo { path: exe_path };
}
}
let homebrew_paths = [
PathBuf::from("/opt/homebrew/bin/research-master"),
PathBuf::from("/usr/local/bin/research-master"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/research-master"),
];
for hb_path in &homebrew_paths {
if exe_path == *hb_path {
return InstallationMethod::Homebrew { path: exe_path };
}
}
InstallationMethod::Direct { path: exe_path }
}
pub fn get_update_instructions(method: &InstallationMethod) -> String {
match method {
InstallationMethod::Homebrew { .. } => {
"You seem to have installed via Homebrew. Run:\n brew upgrade research-master".to_string()
}
InstallationMethod::Cargo { .. } => {
"You seem to have installed via cargo. Run:\n cargo install research-master".to_string()
}
InstallationMethod::Direct { .. } => {
"I'll download and install the latest version for you.".to_string()
}
InstallationMethod::Unknown => {
"Unable to detect installation method.\n\nIf you installed via:\n - Homebrew: run 'brew upgrade research-master'\n - cargo: run 'cargo install research-master'\n - Direct download: I'll download the latest binary".to_string()
}
}
}
#[derive(Debug, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub version: String,
pub body: String,
pub published_at: String,
pub assets: Vec<ReleaseAsset>,
}
#[derive(Debug, Clone)]
pub struct ReleaseAsset {
pub name: String,
pub download_url: String,
}
pub async fn fetch_latest_release() -> Result<ReleaseInfo> {
let client = reqwest::Client::new();
let response = client
.get("https://api.github.com/repos/hongkongkiwi/research-master/releases/latest")
.header("User-Agent", "research-master")
.send()
.await
.context("Failed to fetch latest release")?;
if !response.status().is_success() {
bail!(
"GitHub API request failed with status: {}",
response.status()
);
}
let json: serde_json::Value = response
.json()
.await
.context("Failed to parse release info")?;
let tag_name = json["tag_name"]
.as_str()
.context("Missing tag_name")?
.to_string();
let version = tag_name.trim_start_matches('v').to_string();
let body = json["body"].as_str().unwrap_or("").to_string();
let published_at = json["published_at"].as_str().unwrap_or("").to_string();
let mut assets = Vec::new();
if let Some(assets_array) = json["assets"].as_array() {
for asset in assets_array {
if let (Some(name), Some(download_url)) = (
asset["name"].as_str(),
asset["browser_download_url"].as_str(),
) {
assets.push(ReleaseAsset {
name: name.to_string(),
download_url: download_url.to_string(),
});
}
}
}
Ok(ReleaseInfo {
tag_name,
version,
body,
published_at,
assets,
})
}
pub fn get_current_target() -> &'static str {
let target = std::env::consts::ARCH;
let os = if cfg!(target_os = "linux") {
if cfg!(target_env = "musl") {
"unknown-linux-musl"
} else {
"unknown-linux-gnu"
}
} else if cfg!(target_os = "macos") {
"apple-darwin"
} else if cfg!(target_os = "windows") {
"pc-windows-msvc"
} else {
return "";
};
match target {
"x86_64" => {
if os == "apple-darwin" {
"x86_64-apple-darwin"
} else if os == "unknown-linux-musl" {
"x86_64-unknown-linux-musl"
} else if os == "unknown-linux-gnu" {
"x86_64-unknown-linux-gnu"
} else if os == "pc-windows-msvc" {
"x86_64-pc-windows-msvc"
} else {
""
}
}
"aarch64" => {
if os == "apple-darwin" {
"aarch64-apple-darwin"
} else {
""
}
}
_ => "",
}
}
pub fn find_asset_for_platform(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
let target = get_current_target();
if target.is_empty() {
return None;
}
let preferred_ext = if cfg!(target_os = "windows") {
".zip"
} else {
".tar.gz"
};
if let Some(asset) = release
.assets
.iter()
.find(|asset| asset.name.contains(target) && asset.name.ends_with(preferred_ext))
{
return Some(asset);
}
release
.assets
.iter()
.find(|asset| asset.name.contains(target))
}
pub async fn download_and_extract_asset(asset: &ReleaseAsset, temp_dir: &Path) -> Result<PathBuf> {
let client = reqwest::Client::new();
eprintln!("Downloading {}...", asset.name);
let response = client
.get(&asset.download_url)
.send()
.await
.context("Failed to download asset")?;
if !response.status().is_success() {
bail!("Download failed with status: {}", response.status());
}
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
let archive_path = temp_dir.join(&asset.name);
fs::write(&archive_path, &bytes).context("Failed to save archive")?;
let binary_path = if asset.name.ends_with(".tar.gz") {
extract_tar_gz(&archive_path, temp_dir)?
} else if asset.name.ends_with(".zip") {
extract_zip(&archive_path, temp_dir)?
} else {
bail!("Unsupported archive format: {}", asset.name);
};
Ok(binary_path)
}
#[cfg(unix)]
fn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let output = Command::new("tar")
.args([
"xzf",
archive_path.to_str().unwrap(),
"-C",
dest_dir.to_str().unwrap(),
])
.output()
.context("Failed to extract tar.gz")?;
if !output.status.success() {
bail!(
"tar extraction failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
for entry in fs::read_dir(dest_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file()
&& path
.file_name()
.map(|n| n.to_string_lossy().starts_with("research-master"))
.unwrap_or(false)
{
let mut perms = fs::metadata(&path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms)?;
return Ok(path);
}
}
bail!("Could not find binary in archive")
}
#[cfg(windows)]
fn extract_tar_gz(_archive_path: &Path, _dest_dir: &Path) -> Result<PathBuf> {
bail!("tar.gz extraction on Windows requires additional dependencies")
}
#[cfg(windows)]
fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<PathBuf> {
use zip::ZipArchive;
let file = fs::File::open(archive_path)?;
let mut archive = ZipArchive::new(file)?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let out_path = dest_dir.join(entry.name());
if entry.is_dir() {
fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out_file = fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out_file)?;
}
}
for entry in fs::read_dir(dest_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file()
&& path
.file_name()
.map(|n| n.to_string_lossy().starts_with("research-master"))
.unwrap_or(false)
{
return Ok(path);
}
}
bail!("Could not find binary in archive")
}
#[cfg(unix)]
fn extract_zip(_archive_path: &Path, _dest_dir: &Path) -> Result<PathBuf> {
bail!("zip extraction on Unix requires additional dependencies")
}
pub fn replace_binary(current: &Path, new: &Path) -> Result<()> {
#[cfg(unix)]
{
let temp_path = current.with_file_name(format!(
"{}.new",
current.file_name().unwrap().to_string_lossy()
));
fs::copy(new, &temp_path)?;
std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755))?;
let backup_path = current.with_file_name(format!(
"{}.backup",
current.file_name().unwrap().to_string_lossy()
));
if current.exists() {
fs::rename(current, &backup_path)?;
}
fs::rename(&temp_path, current)?;
if backup_path.exists() {
fs::remove_file(&backup_path)?;
}
Ok(())
}
#[cfg(windows)]
{
let new_path = current.with_extension(".exe.new");
fs::copy(new, &new_path)?;
eprintln!(
"New binary downloaded to: {}. Please restart your terminal to use the new version.",
new_path.display()
);
Ok(())
}
}
pub fn cleanup_temp_files(files: Vec<PathBuf>) {
for file in files {
if file.exists() {
let _ = fs::remove_file(file);
}
}
}
pub async fn fetch_and_verify_sha256(asset_name: &str, _temp_dir: &Path) -> Result<String> {
let client = reqwest::Client::new();
let checksums_url =
"https://github.com/hongkongkiwi/research-master/releases/download/latest/SHA256SUMS.txt";
eprintln!("Downloading SHA256 checksums...");
let response = client
.get(checksums_url)
.header("User-Agent", "research-master")
.send()
.await
.context("Failed to download checksums file")?;
if !response.status().is_success() {
bail!("Failed to download checksums (HTTP {})", response.status());
}
let checksums_text = response.text().await.context("Failed to read checksums")?;
for line in checksums_text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let hash = parts[0];
let filename = parts.last().unwrap_or(&"");
let normalized_filename = filename.trim_start_matches("./");
if normalized_filename == asset_name || filename.contains(asset_name) {
return Ok(hash.to_string());
}
}
}
bail!("Checksum not found for {}", asset_name)
}
pub fn compute_sha256(file_path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let data = fs::read(file_path).context("Failed to read file for checksum")?;
let mut hasher = Sha256::new();
hasher.update(&data);
let result = hasher.finalize();
Ok(format!("{:x}", result))
}
pub fn verify_sha256(file_path: &Path, expected_hash: &str) -> Result<bool> {
let actual_hash = compute_sha256(file_path)?;
if actual_hash == expected_hash {
Ok(true)
} else {
eprintln!("SHA256 mismatch!");
eprintln!("Expected: {}", expected_hash);
eprintln!("Actual: {}", actual_hash);
Ok(false)
}
}
pub async fn fetch_sha256_signature() -> Result<String> {
let client = reqwest::Client::new();
let signature_url = "https://github.com/hongkongkiwi/research-master/releases/download/latest/SHA256SUMS.txt.asc";
eprintln!("Downloading GPG signature...");
let response = client
.get(signature_url)
.header("User-Agent", "research-master")
.send()
.await
.context("Failed to download GPG signature")?;
if !response.status().is_success() {
bail!(
"Failed to download GPG signature (HTTP {})",
response.status()
);
}
let signature = response.text().await.context("Failed to read signature")?;
Ok(signature)
}
pub fn verify_gpg_signature(sha256sums_path: &Path, signature: &str) -> Result<bool> {
use std::io::Write as _;
if !is_command_available("gpg") {
#[cfg(windows)]
{
eprintln!("WARNING: GPG is not installed or not in PATH.");
eprintln!("On Windows, install GPG from https://www.gpg4win.org/");
}
#[cfg(not(windows))]
{
eprintln!("WARNING: GPG is not installed or not in PATH.");
eprintln!("Install GPG with your package manager (e.g., brew install gnupg)");
}
eprintln!("Skipping GPG signature verification.");
return Ok(false);
}
let sig_path = sha256sums_path.with_extension("txt.asc");
let mut sig_file = std::fs::File::create(&sig_path)?;
sig_file.write_all(signature.as_bytes())?;
sig_file.flush()?;
let output = Command::new("gpg")
.args([
"--verify",
sig_path.to_str().unwrap(),
sha256sums_path.to_str().unwrap(),
])
.output()
.context("Failed to run gpg")?;
let _ = std::fs::remove_file(&sig_path);
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Good signature") || stderr.contains("gpg: Good signature") {
if let Ok(fingerprint) = std::env::var("GPG_FINGERPRINT") {
if stderr.contains(&fingerprint) || output.status.success() {
eprintln!("GPG signature verified successfully!");
return Ok(true);
} else {
eprintln!("WARNING: Signature is good but from unexpected signer!");
eprintln!("Expected fingerprint: {}", fingerprint);
return Ok(false);
}
}
eprintln!("GPG signature verified successfully!");
return Ok(true);
}
if stderr.contains("BAD signature") || stderr.contains("gpg: BAD signature") {
eprintln!("ERROR: GPG signature verification FAILED!");
eprintln!("{}", stderr);
return Ok(false);
}
if stderr.contains("no public key") || stderr.contains("gpg: Can't check signature") {
eprintln!("WARNING: GPG is not configured properly.");
eprintln!("To enable GPG verification, either:");
eprintln!(" 1. Install GPG and import the maintainer's public key");
eprintln!(" 2. Set GPG_FINGERPRINT to skip signer verification");
return Ok(false);
}
eprintln!("GPG verification result: {}", stderr);
Ok(false)
}