rdar 0.5.12

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.12";
const BASE_URL: &str = "https://github.com/Sanix-Darker/radar/releases/download/v0.5.12";
const LINUX_X86_64_SHA256: &str =
    "24ee2e951fd45cf4bf0933cda39fc2c5c5870a4f1b0b8fa885fa9a533f4ca8dd";
const LINUX_X86_64_ASSET: &str = "radar-v0.5.12-linux-x86_64.tar.gz";
const LINUX_AARCH64_SHA256: &str =
    "8e2b5ef58a140ac611a432e91948aac48800f4d3afddf95bd18f824634302d59";
const LINUX_AARCH64_ASSET: &str = "radar-v0.5.12-linux-aarch64.tar.gz";
const MACOS_X86_64_SHA256: &str =
    "f041cb6a316fe07dbc5507f885528f32396b525c82c90cddb8fc16e5a7f69a40";
const MACOS_X86_64_ASSET: &str = "radar-v0.5.12-macos-x86_64.tar.gz";
const MACOS_AARCH64_SHA256: &str =
    "4c2adddb0709c4e0e77fb82dd70740804ad04ebc31d232913dfb3f29bb7d6278";
const MACOS_AARCH64_ASSET: &str = "radar-v0.5.12-macos-aarch64.tar.gz";
const WINDOWS_X86_64_SHA256: &str =
    "62f86d8c866fbe7aa417b8251c4a08dce5f832b50742cac75a169aaeea622ee6";
const WINDOWS_X86_64_ASSET: &str = "radar-v0.5.12-windows-x86_64.zip";
const WINDOWS_AARCH64_SHA256: &str =
    "61cdc4afaeade1bbe354670baee30002ba3d85376037b1445bc9b5f8af616fb1";
const WINDOWS_AARCH64_ASSET: &str = "radar-v0.5.12-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.12");
        assert_eq!(env!("CARGO_PKG_VERSION"), "0.5.12");
    }
}