rdar 0.5.14

Installer and launcher for radar, the fast local repository router
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};

const NATIVE_VERSION: &str = "0.5.14";
const BASE_URL: &str = "https://github.com/Sanix-Darker/radar/releases/download/v0.5.14";
const LINUX_X86_64_SHA256: &str =
    "08cbb53a0d49969f30a2067e5beda87d2753fbce523e0641539a53903f0b355e";
const LINUX_X86_64_ASSET: &str = "radar-v0.5.14-linux-x86_64.tar.gz";
const LINUX_AARCH64_SHA256: &str =
    "c9be696bb943225bc1a579952886d781a2f897c1a0925ab9dc69c4a85a373950";
const LINUX_AARCH64_ASSET: &str = "radar-v0.5.14-linux-aarch64.tar.gz";
const MACOS_X86_64_SHA256: &str =
    "22aa0c181472dfbd8208708050c89c6ad16efc4ba00f9391439b360160618898";
const MACOS_X86_64_ASSET: &str = "radar-v0.5.14-macos-x86_64.tar.gz";
const MACOS_AARCH64_SHA256: &str =
    "94eb279728ef64d9e63ab4e50e0c9545fa9d6e02c17a3e0ed72a6ab264880d65";
const MACOS_AARCH64_ASSET: &str = "radar-v0.5.14-macos-aarch64.tar.gz";
const WINDOWS_X86_64_SHA256: &str =
    "7273f745f53eb8aaa8c967ca31c03eddae15fd02ccfdabe45143c475bea76550";
const WINDOWS_X86_64_ASSET: &str = "radar-v0.5.14-windows-x86_64.zip";
const WINDOWS_AARCH64_SHA256: &str =
    "a03b6c8ed2bfac98fe7a797d049d2d33daa3e1472a928fab96b6fb63dad4fa9b";
const WINDOWS_AARCH64_ASSET: &str = "radar-v0.5.14-windows-aarch64.zip";

fn target() -> Result<&'static str, String> {
    match (env::consts::OS, env::consts::ARCH) {
        ("linux", "x86_64") => Ok("linux-x86_64"),
        ("linux", "aarch64") => Ok("linux-aarch64"),
        ("macos", "x86_64") => Ok("macos-x86_64"),
        ("macos", "aarch64") => Ok("macos-aarch64"),
        ("windows", "x86_64") => Ok("windows-x86_64"),
        ("windows", "aarch64") => Ok("windows-aarch64"),
        pair => Err(format!("radar has no release for {}-{}", pair.0, pair.1)),
    }
}

fn asset(target: &str) -> Result<&'static str, String> {
    match target {
        "linux-x86_64" => Ok(LINUX_X86_64_ASSET),
        "linux-aarch64" => Ok(LINUX_AARCH64_ASSET),
        "macos-x86_64" => Ok(MACOS_X86_64_ASSET),
        "macos-aarch64" => Ok(MACOS_AARCH64_ASSET),
        "windows-x86_64" => Ok(WINDOWS_X86_64_ASSET),
        "windows-aarch64" => Ok(WINDOWS_AARCH64_ASSET),
        _ => Err("release asset is unavailable for this target".to_string()),
    }
}

fn expected_sha256(target: &str) -> Result<&'static str, String> {
    match target {
        "linux-x86_64" => Ok(LINUX_X86_64_SHA256),
        "linux-aarch64" => Ok(LINUX_AARCH64_SHA256),
        "macos-x86_64" => Ok(MACOS_X86_64_SHA256),
        "macos-aarch64" => Ok(MACOS_AARCH64_SHA256),
        "windows-x86_64" => Ok(WINDOWS_X86_64_SHA256),
        "windows-aarch64" => Ok(WINDOWS_AARCH64_SHA256),
        _ => Err("release hash is unavailable for this target".to_string()),
    }
}

fn binary_name(target: &str) -> &'static str {
    if target.starts_with("windows-") {
        "radar.exe"
    } else {
        "radar"
    }
}

fn cache_root() -> Result<PathBuf, String> {
    if let Some(path) = env::var_os("XDG_CACHE_HOME") {
        return Ok(PathBuf::from(path).join("rdar"));
    }
    env::var_os("HOME")
        .map(PathBuf::from)
        .map(|home| home.join(".cache/rdar"))
        .ok_or_else(|| "HOME and XDG_CACHE_HOME are unset".to_string())
}

fn run(program: &str, arguments: &[&str]) -> Result<(), String> {
    let status = Command::new(program)
        .args(arguments)
        .status()
        .map_err(|error| format!("cannot run {program}: {error}"))?;
    if status.success() {
        Ok(())
    } else {
        Err(format!("{program} failed with {status}"))
    }
}

fn command_sha256(program: &str, arguments: &[&str]) -> Result<String, String> {
    let output = Command::new(program)
        .args(arguments)
        .output()
        .map_err(|error| format!("cannot run {program}: {error}"))?;
    if !output.status.success() {
        return Err(format!("{program} failed while computing SHA-256"));
    }
    Ok(String::from_utf8_lossy(&output.stdout)
        .split_whitespace()
        .next()
        .unwrap_or("")
        .to_ascii_lowercase())
}

fn compute_sha256(archive: &Path) -> Result<String, String> {
    let archive = archive
        .to_str()
        .ok_or_else(|| "archive path is not UTF-8".to_string())?;
    if cfg!(windows) {
        return command_sha256(
            "powershell",
            &[
                "-NoProfile",
                "-NonInteractive",
                "-Command",
                "(Get-FileHash -Algorithm SHA256 -LiteralPath $args[0]).Hash.ToLowerInvariant()",
                archive,
            ],
        );
    }
    if Command::new("sha256sum").arg("--version").output().is_ok() {
        command_sha256("sha256sum", &[archive])
    } else {
        command_sha256("shasum", &["-a", "256", archive])
    }
}

fn verify(archive: &Path, checksum: &Path, target: &str) -> Result<(), String> {
    let expected = expected_sha256(target)?;
    let remote = fs::read_to_string(checksum)
        .map_err(|error| format!("cannot read remote checksum: {error}"))?
        .split_whitespace()
        .next()
        .unwrap_or("")
        .to_ascii_lowercase();
    if remote != expected {
        return Err(format!(
            "remote checksum mismatch: expected {expected}, got {remote}"
        ));
    }
    let actual = compute_sha256(archive)?;
    if actual == expected {
        Ok(())
    } else {
        Err(format!(
            "embedded checksum mismatch: expected {expected}, got {actual}"
        ))
    }
}

fn extract(archive: &Path, temporary: &Path, target: &str) -> Result<PathBuf, String> {
    let archive = archive
        .to_str()
        .ok_or_else(|| "cache path is not UTF-8".to_string())?;
    let temporary = temporary
        .to_str()
        .ok_or_else(|| "cache path is not UTF-8".to_string())?;
    if target.starts_with("windows-") {
        run("tar", &["-xf", archive, "-C", temporary])?;
    } else {
        run("tar", &["-xzf", archive, "-C", temporary])?;
    }
    let extracted = Path::new(temporary).join(binary_name(target));
    if extracted.is_file() {
        Ok(extracted)
    } else {
        Err(format!(
            "release archive does not contain {}",
            binary_name(target)
        ))
    }
}

fn install(binary: &Path, target: &str) -> Result<(), String> {
    let parent = binary
        .parent()
        .ok_or_else(|| "invalid cache path".to_string())?;
    fs::create_dir_all(parent).map_err(|error| format!("cannot create cache: {error}"))?;
    let temporary = parent.join(format!("install-{}", std::process::id()));
    if temporary.exists() {
        fs::remove_dir_all(&temporary)
            .map_err(|error| format!("cannot clear temporary install: {error}"))?;
    }
    fs::create_dir(&temporary)
        .map_err(|error| format!("cannot create temporary install: {error}"))?;
    let asset = asset(target)?;
    let archive = temporary.join(asset);
    let checksum = temporary.join(format!("{asset}.sha256.remote"));
    let archive_url = format!("{BASE_URL}/{asset}");
    let checksum_url = format!("{archive_url}.sha256");
    let archive_path = archive
        .to_str()
        .ok_or_else(|| "cache path is not UTF-8".to_string())?;
    let checksum_path = checksum
        .to_str()
        .ok_or_else(|| "cache path is not UTF-8".to_string())?;
    run(
        "curl",
        &[
            "-fsSL",
            "--proto",
            "=https",
            "-o",
            archive_path,
            &archive_url,
        ],
    )?;
    run(
        "curl",
        &[
            "-fsSL",
            "--proto",
            "=https",
            "-o",
            checksum_path,
            &checksum_url,
        ],
    )?;
    verify(&archive, &checksum, target)?;
    let extracted = extract(&archive, &temporary, target)?;
    fs::rename(extracted, binary).map_err(|error| format!("cannot install radar: {error}"))?;
    fs::remove_dir_all(temporary)
        .map_err(|error| format!("cannot remove temporary install: {error}"))?;
    Ok(())
}

fn execute(binary: &Path) -> Result<ExitCode, String> {
    let status = Command::new(binary)
        .args(env::args_os().skip(1))
        .status()
        .map_err(|error| format!("cannot start radar: {error}"))?;
    Ok(ExitCode::from(
        status.code().unwrap_or(1).clamp(0, 255) as u8
    ))
}

fn main() -> ExitCode {
    let result = (|| {
        let target = target()?;
        let binary = cache_root()?.join(format!(
            "v{NATIVE_VERSION}/{target}/{}",
            binary_name(target)
        ));
        if !binary.is_file() {
            install(&binary, target)?;
        }
        execute(&binary)
    })();
    match result {
        Ok(code) => code,
        Err(error) => {
            eprintln!("radar installer: {error}");
            eprintln!("manual downloads: https://radar.sanixdk.xyz/#start");
            ExitCode::FAILURE
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn supported_target_has_a_pinned_archive_hash() {
        for target in [
            "linux-x86_64",
            "linux-aarch64",
            "macos-x86_64",
            "macos-aarch64",
            "windows-x86_64",
            "windows-aarch64",
        ] {
            let hash = expected_sha256(target).unwrap();
            assert_eq!(hash.len(), 64);
            assert!(hash.bytes().all(|byte| byte.is_ascii_hexdigit()));
            assert!(
                asset(target)
                    .unwrap()
                    .starts_with(&format!("radar-v{NATIVE_VERSION}-"))
            );
        }
    }

    #[test]
    fn native_version_matches_the_pinned_asset() {
        for target in [
            "linux-x86_64",
            "linux-aarch64",
            "macos-x86_64",
            "macos-aarch64",
            "windows-x86_64",
            "windows-aarch64",
        ] {
            assert!(
                asset(target)
                    .unwrap()
                    .starts_with(&format!("radar-v{NATIVE_VERSION}-"))
            );
        }
        assert_eq!(NATIVE_VERSION, "0.5.14");
        assert_eq!(env!("CARGO_PKG_VERSION"), "0.5.14");
    }
}