use std::io::{self, Cursor, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Deserialize;
use sha2::{Digest, Sha256};
const GITHUB_API: &str =
"https://api.github.com/repos/HuginCyber/Hugin/releases/latest";
const FORGEJO_API: &str =
"http://hugin.nu:3000/api/v1/repos/andrei/hugin/releases/latest";
const USER_AGENT: &str = concat!("hugincyber-installer/", env!("CARGO_PKG_VERSION"));
#[derive(Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
fn asset_prefix() -> Result<&'static str, String> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("linux", "x86_64") => Ok("hugin-cli-linux-x86_64"),
("macos", "aarch64") => Ok("hugin-cli-darwin-aarch64"),
("macos", "x86_64") => Ok("hugin-cli-darwin-x86_64"),
("linux", "aarch64") => Err(
"Linux aarch64 is not yet available. Vote via GitHub star: \
https://github.com/HuginCyber/Hugin"
.into(),
),
("windows", _) => Err(
"Windows is not planned. Hugin runs on Linux and macOS.".into(),
),
(os, arch) => Err(format!(
"Unsupported platform: {os}-{arch}. \
Hugin runs on linux-x86_64, macos-aarch64, and macos-x86_64."
)),
}
}
fn install_dir() -> Result<PathBuf, String> {
if let Some(home) = std::env::var_os("HOME") {
let local_bin = Path::new(&home).join(".local").join("bin");
if local_bin.exists() || std::fs::create_dir_all(&local_bin).is_ok() {
return Ok(local_bin);
}
}
if std::env::consts::OS == "macos" {
let usr_local = Path::new("/usr/local/bin");
if usr_local.exists() {
return Ok(usr_local.to_path_buf());
}
}
Err(
"Could not find a writable install directory. \
Set HOME or ensure ~/.local/bin or /usr/local/bin is writable."
.into(),
)
}
fn http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
fn fetch_release_from(url: &str) -> Result<Release, String> {
let resp = http_client()?
.get(url)
.header("Accept", "application/json")
.send()
.map_err(|e| format!("Failed to fetch release info from {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"{url} returned HTTP {} {}",
resp.status().as_u16(),
resp.status().canonical_reason().unwrap_or("error")
));
}
resp.json::<Release>()
.map_err(|e| format!("Failed to parse release JSON from {url}: {e}"))
}
fn fetch_release() -> Result<(Release, &'static str), String> {
match fetch_release_from(GITHUB_API) {
Ok(r) => Ok((r, "GitHub")),
Err(github_err) => {
eprintln!(" \x1b[33mGitHub unavailable:\x1b[0m {github_err}");
eprintln!(" \x1b[33mFalling back to Forgejo...\x1b[0m");
fetch_release_from(FORGEJO_API).map(|r| (r, "Forgejo"))
}
}
}
fn find_asset<'a>(release: &'a Release, prefix: &str) -> Result<&'a Asset, String> {
let tarball_name = format!("{prefix}.tar.gz");
if let Some(a) = release.assets.iter().find(|a| a.name == tarball_name) {
return Ok(a);
}
if let Some(a) = release.assets.iter().find(|a| a.name == prefix) {
return Ok(a);
}
if let Some(a) = release
.assets
.iter()
.find(|a| a.name.starts_with(prefix) && !a.name.ends_with(".sha256"))
{
return Ok(a);
}
Err(format!(
"No matching asset '{prefix}' (or '{tarball_name}') in release {}.\n\
Available assets:\n{}",
release.tag_name,
release
.assets
.iter()
.map(|a| format!(" - {} ({} MB)", a.name, a.size / 1_048_576))
.collect::<Vec<_>>()
.join("\n")
))
}
fn fetch_sha256(release: &Release, asset_name: &str) -> Option<String> {
let sha_name = format!("{asset_name}.sha256");
let sha_asset = release.assets.iter().find(|a| a.name == sha_name)?;
let resp = http_client().ok()?
.get(&sha_asset.browser_download_url)
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
let text = resp.text().ok()?;
text.split_whitespace().next().map(|s| s.to_lowercase())
}
fn download_asset(url: &str) -> Result<Vec<u8>, String> {
print!(" Downloading... ");
io::stdout().flush().ok();
let resp = http_client()?
.get(url)
.send()
.map_err(|e| format!("Failed to download: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Download failed: HTTP {}", resp.status()));
}
let bytes = resp
.bytes()
.map_err(|e| format!("Failed to read download: {e}"))?;
println!("{} MB", bytes.len() / 1_048_576);
Ok(bytes.to_vec())
}
fn verify_sha256(data: &[u8], expected_hex: &str) -> Result<(), String> {
let mut hasher = Sha256::new();
hasher.update(data);
let actual = hex::encode(hasher.finalize());
if actual == expected_hex {
Ok(())
} else {
Err(format!(
"SHA256 mismatch!\n expected: {expected_hex}\n actual: {actual}"
))
}
}
fn extract_from_tarball(archive: &[u8], dest: &Path) -> Result<PathBuf, String> {
let cursor = Cursor::new(archive);
let gz = flate2::read::GzDecoder::new(cursor);
let mut tar = tar::Archive::new(gz);
for entry in tar.entries().map_err(|e| format!("Failed to read tar: {e}"))? {
let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {e}"))?;
let name = entry
.path()
.map_err(|e| format!("Failed to get entry path: {e}"))?
.into_owned();
if name.file_name() == Some(std::ffi::OsStr::new("hugin")) {
let dest_path = dest.join("hugin");
entry
.unpack(dest)
.map_err(|e| format!("Failed to extract hugin: {e}"))?;
set_executable(&dest_path)?;
return Ok(dest_path);
}
}
Err("hugin binary not found in archive".into())
}
fn write_bare_binary(data: &[u8], dest: &Path) -> Result<PathBuf, String> {
let dest_path = dest.join("hugin");
std::fs::write(&dest_path, data)
.map_err(|e| format!("Failed to write binary: {e}"))?;
set_executable(&dest_path)?;
Ok(dest_path)
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("Failed to set executable permissions: {e}"))
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<(), String> {
Ok(())
}
fn is_on_path(dir: &Path) -> bool {
if let Ok(path_var) = std::env::var("PATH") {
for entry in path_var.split(':') {
if Path::new(entry) == dir {
return true;
}
}
}
false
}
fn main() {
let version = env!("CARGO_PKG_VERSION");
println!("\n \x1b[1;38;5;208mHuginCyber\x1b[0m installer v{version}\n");
println!(" The security intercepting proxy for hackers.");
println!(" https://hugin.nu\n");
let prefix = match asset_prefix() {
Ok(p) => p,
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
print!(" Checking latest release... ");
io::stdout().flush().ok();
let (release, _source) = match fetch_release() {
Ok((r, src)) => {
println!("{} (via {src})", r.tag_name);
(r, src)
}
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
let asset = match find_asset(&release, prefix) {
Ok(a) => a,
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
let archive = match download_asset(&asset.browser_download_url) {
Ok(data) => data,
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
if let Some(expected) = fetch_sha256(&release, &asset.name) {
print!(" Verifying SHA256... ");
io::stdout().flush().ok();
match verify_sha256(&archive, &expected) {
Ok(()) => println!("ok"),
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
}
} else {
eprintln!(" \x1b[33mWarning:\x1b[0m No SHA256 sidecar found — skipping verification.");
}
let install_dir = match install_dir() {
Ok(dir) => dir,
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
print!(" Extracting to {}... ", install_dir.display());
io::stdout().flush().ok();
let binary = if asset.name.ends_with(".tar.gz") {
extract_from_tarball(&archive, &install_dir)
} else {
write_bare_binary(&archive, &install_dir)
};
let binary = match binary {
Ok(p) => {
println!("done");
p
}
Err(e) => {
eprintln!("\n \x1b[31mError:\x1b[0m {e}");
std::process::exit(1);
}
};
if let Ok(output) = Command::new(&binary).arg("--version").output() {
let v = String::from_utf8_lossy(&output.stdout);
let v = v.lines().next().unwrap_or("(unknown version)");
println!("\n \x1b[32mInstalled:\x1b[0m {v}");
} else {
println!("\n \x1b[32mInstalled:\x1b[0m {}", binary.display());
}
if !is_on_path(&install_dir) {
eprintln!(
"\n \x1b[33mWarning:\x1b[0m {} is not on your PATH.",
install_dir.display()
);
eprintln!(" Add it to your shell profile:");
eprintln!(" export PATH=\"{}:$PATH\"", install_dir.display());
}
println!("\n Run \x1b[1mhugin\x1b[0m to start.\n");
}
mod hex {
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
}