hugincyber 0.1.3

A high-performance security proxy and vulnerability scanner for modern penetration testing — 46+ active check classes, OOB collaborator, MCP-driven autonomous agent, and bug bounty platform integrations in a single binary.
use std::io::{self, Write, Read, Cursor};
use std::path::{Path, PathBuf};
use std::process::Command;

use serde::Deserialize;

const GITHUB_API: &str = "https://api.github.com/repos/HuginCyber/Hugin/releases/latest";
const USER_AGENT: &str = "hugincyber-installer/0.1.3";

#[derive(Deserialize)]
struct Release {
    tag_name: String,
    assets: Vec<Asset>,
}

#[derive(Deserialize)]
struct Asset {
    name: String,
    browser_download_url: String,
    size: u64,
}

fn detect_asset() -> 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.tar.gz"),
        ("macos", "aarch64") => Ok("hugin-cli-darwin-aarch64.tar.gz"),
        ("macos", "x86_64") => Ok("hugin-cli-darwin-x86_64.tar.gz"),
        ("linux", "aarch64") => Err("Linux aarch64 builds are not yet available. Star the repo to vote for it.".into()),
        ("windows", _) => Err("Windows is not supported yet. 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> {
    // Prefer ~/.local/bin (on PATH on most modern Linux), fall back to /usr/local/bin
    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);
        }
    }

    // macOS: /usr/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 fetch_release() -> Result<Release, String> {
    let response = reqwest::blocking::Client::builder()
        .user_agent(USER_AGENT)
        .build()
        .map_err(|e| format!("Failed to build HTTP client: {e}"))?
        .get(GITHUB_API)
        .header("Accept", "application/vnd.github+json")
        .send()
        .map_err(|e| format!("Failed to fetch release info: {e}"))?;

    if !response.status().is_success() {
        return Err(format!(
            "GitHub API returned HTTP {}: {}",
            response.status().as_u16(),
            response.status().canonical_reason().unwrap_or("error")
        ));
    }

    response
        .json::<Release>()
        .map_err(|e| format!("Failed to parse release JSON: {e}"))
}

fn download_asset(url: &str) -> Result<Vec<u8>, String> {
    print!("  Downloading... ");
    io::stdout().flush().ok();

    let response = reqwest::blocking::Client::builder()
        .user_agent(USER_AGENT)
        .build()
        .map_err(|e| format!("Failed to build HTTP client: {e}"))?
        .get(url)
        .send()
        .map_err(|e| format!("Failed to download: {e}"))?;

    if !response.status().is_success() {
        return Err(format!("Download failed: HTTP {}", response.status()));
    }

    let bytes = response
        .bytes()
        .map_err(|e| format!("Failed to read download: {e}"))?;

    println!("{} MB", bytes.len() / 1_048_576);
    Ok(bytes.to_vec())
}

fn extract_hugin(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();

        // We only want the `hugin` binary
        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}"))?;

            // Make executable
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                std::fs::set_permissions(&dest_path, std::fs::Permissions::from_mode(0o755))
                    .map_err(|e| format!("Failed to set executable permissions: {e}"))?;
            }

            return Ok(dest_path);
        }
    }

    Err("hugin binary not found in archive".into())
}

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 asset_name = match detect_asset() {
        Ok(name) => name,
        Err(e) => {
            eprintln!("\n  \x1b[31mError:\x1b[0m {e}");
            std::process::exit(1);
        }
    };

    // 2. Fetch latest release
    print!("  Checking latest release... ");
    io::stdout().flush().ok();
    let release = match fetch_release() {
        Ok(r) => {
            println!("{}", r.tag_name);
            r
        }
        Err(e) => {
            eprintln!("\n  \x1b[31mError:\x1b[0m {e}");
            std::process::exit(1);
        }
    };

    // 3. Find matching asset
    let asset = release
        .assets
        .iter()
        .find(|a| a.name == asset_name)
        .unwrap_or_else(|| {
            eprintln!(
                "\n  \x1b[31mError:\x1b[0m No matching asset '{asset_name}' in release {}.",
                release.tag_name
            );
            eprintln!("  Available assets:");
            for a in &release.assets {
                eprintln!("    - {} ({} MB)", a.name, a.size / 1_048_576);
            }
            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. Install
    let install_dir = match install_dir() {
        Ok(dir) => dir,
        Err(e) => {
            eprintln!("\n  \x1b[31mError:\x3b[0m {e}");
            std::process::exit(1);
        }
    };

    print!("  Extracting to {}... ", install_dir.display());
    io::stdout().flush().ok();
    let binary = match extract_hugin(&archive, &install_dir) {
        Ok(p) => {
            println!("done");
            p
        }
        Err(e) => {
            eprintln!("\n  \x1b[31mError:\x3b[0m {e}");
            std::process::exit(1);
        }
    };

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

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