rdar 0.5.1

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.0";
const BASE_URL: &str = "https://radar.sanixdk.xyz/downloads";
const LINUX_X86_64_SHA256: &str =
    "73f8faa5f0558c91fb8e2966005579c66c76ddb1198a8aec10f850c6d116f2d2";
const LINUX_X86_64_ASSET: &str = "radar-v0.5.0-linux-x86_64-73f8faa5.tar.gz";

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

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

fn verify(archive: &Path, checksum: &Path) -> Result<(), String> {
    let archive = archive
        .to_str()
        .ok_or_else(|| "archive path is not UTF-8".to_string())?;
    let checksum = checksum
        .to_str()
        .ok_or_else(|| "checksum path is not UTF-8".to_string())?;
    let directory = archive
        .rsplit_once('/')
        .map_or(".", |(directory, _)| directory);
    let name = archive.rsplit('/').next().unwrap_or(archive);
    let copied = Path::new(directory).join(format!("{name}.sha256"));
    fs::copy(checksum, &copied).map_err(|error| format!("cannot stage checksum: {error}"))?;
    let checksum_name = copied
        .file_name()
        .and_then(|value| value.to_str())
        .ok_or_else(|| "checksum filename is not UTF-8".to_string())?;
    let result = if Command::new("sha256sum").arg("--version").output().is_ok() {
        run_at(Path::new(directory), "sha256sum", &["-c", checksum_name])
    } else {
        run_at(
            Path::new(directory),
            "shasum",
            &["-a", "256", "-c", checksum_name],
        )
    };
    let _ = fs::remove_file(copied);
    result?;
    let expected = match target()? {
        "linux-x86_64" => LINUX_X86_64_SHA256,
        _ => return Err("release hash is unavailable for this target".to_string()),
    };
    let output = Command::new("sha256sum")
        .arg(archive)
        .output()
        .map_err(|error| format!("cannot compute embedded checksum: {error}"))?;
    if !output.status.success() {
        return Err("sha256sum failed while checking embedded hash".to_string());
    }
    let actual = String::from_utf8_lossy(&output.stdout)
        .split_whitespace()
        .next()
        .unwrap_or("")
        .to_string();
    if actual == expected {
        Ok(())
    } else {
        Err(format!(
            "embedded checksum mismatch: expected {expected}, got {actual}"
        ))
    }
}

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 = match target {
        "linux-x86_64" => LINUX_X86_64_ASSET.to_string(),
        _ => return Err("release asset is unavailable for this target".to_string()),
    };
    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)?;
    run(
        "tar",
        &["-xzf", archive_path, "-C", temporary.to_str().unwrap_or("")],
    )?;
    let extracted = temporary.join("radar");
    if !extracted.is_file() {
        return Err("release archive does not contain radar".to_string());
    }
    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}/radar"));
        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() {
        if env::consts::OS == "linux" && env::consts::ARCH == "x86_64" {
            assert_eq!(target().unwrap(), "linux-x86_64");
            assert_eq!(LINUX_X86_64_SHA256.len(), 64);
            assert!(
                LINUX_X86_64_SHA256
                    .bytes()
                    .all(|byte| byte.is_ascii_hexdigit())
            );
        }
    }

    #[test]
    fn native_version_matches_the_pinned_asset() {
        assert!(LINUX_X86_64_ASSET.starts_with(&format!("radar-v{NATIVE_VERSION}-")));
        assert_eq!(env!("CARGO_PKG_VERSION"), "0.5.1");
    }
}