hugincyber 0.2.0

Installer for Hugin — an intercepting proxy, web scanner, and bug bounty toolkit in a single binary (GUI + CLI).
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,
}

/// The asset we want for this platform. Returns the filename prefix
/// (e.g. "hugin-cli-darwin-aarch64") — we match it flexibly against
/// whatever the release actually published (with or without .tar.gz).
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}"))
}

/// Try GitHub first, fall back to Forgejo. Returns the release + which
/// source it came from (for display).
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"))
        }
    }
}

/// Find the asset matching our platform prefix. Accepts both
/// `hugin-cli-{platform}.tar.gz` and bare `hugin-cli-{platform}`.
fn find_asset<'a>(release: &'a Release, prefix: &str) -> Result<&'a Asset, String> {
    // Prefer .tar.gz if available
    let tarball_name = format!("{prefix}.tar.gz");
    if let Some(a) = release.assets.iter().find(|a| a.name == tarball_name) {
        return Ok(a);
    }
    // Fall back to bare binary (no .tar.gz extension)
    if let Some(a) = release.assets.iter().find(|a| a.name == prefix) {
        return Ok(a);
    }
    // Last resort: any asset starting with the prefix
    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")
    ))
}

/// Fetch the .sha256 sidecar for the asset (if it exists) and return the
/// expected hex digest.
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()?;
    // sha256sum output: "<hex>  filename" — take the first token
    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}"
        ))
    }
}

/// Extract the `hugin` binary from a .tar.gz archive.
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())
}

/// Write a bare binary (no archive) directly to the install dir.
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");

    // 1. Detect platform
    let prefix = match asset_prefix() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("\n  \x1b[31mError:\x1b[0m {e}");
            std::process::exit(1);
        }
    };

    // 2. Fetch latest release (GitHub → Forgejo fallback)
    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);
        }
    };

    // 3. Find matching asset
    let asset = match find_asset(&release, prefix) {
        Ok(a) => a,
        Err(e) => {
            eprintln!("\n  \x1b[31mError:\x1b[0m {e}");
            std::process::exit(1);
        }
    };

    // 4. Download
    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);
        }
    };

    // 5. Verify SHA256 (if sidecar exists)
    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.");
    }

    // 6. Install
    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);
        }
    };

    // 7. Verify the binary runs
    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());
    }

    // 8. PATH check
    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");
}

// Minimal hex encoder (avoids pulling a hex crate dependency).
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
    }
}