owlauth-cli 0.1.2

Control command-line interface for OwlAuth
use std::{
    env,
    path::{Path, PathBuf},
    process::{Command, Stdio},
    time::Duration,
};

#[cfg(not(windows))]
use std::io::Write as _;
#[cfg(windows)]
use std::time::{SystemTime, UNIX_EPOCH};

use clap::Args;
use semver::Version;
use serde::Deserialize;
use thiserror::Error;

const DEFAULT_REPOSITORY: &str = "owlfoundry/owlauth";
const RELEASE_TAG_PREFIX: &str = "cli-v";
#[cfg(not(windows))]
const INSTALLER_SH: &str = include_str!("../assets/install.sh");
#[cfg(windows)]
const INSTALLER_PS1: &str = include_str!("../assets/install.ps1");

#[derive(Debug, Args)]
pub struct UpdateArgs {
    /// Install a specific CLI version instead of the latest stable release.
    #[arg(long, value_name = "SEMVER")]
    version: Option<String>,
    /// Print the selected update without installing it.
    #[arg(long)]
    dry_run: bool,
    /// Reinstall or downgrade even when the selected version is not newer.
    #[arg(short, long)]
    force: bool,
    /// Override the directory containing the installed owlauth binary.
    #[arg(long, value_name = "DIRECTORY")]
    install_dir: Option<PathBuf>,
}

#[derive(Debug, Error)]
pub enum UpdateError {
    #[error("invalid CLI release version {value}: {source}")]
    InvalidVersion {
        value: String,
        source: semver::Error,
    },
    #[error("failed to query CLI releases: {0}")]
    ReleaseQuery(String),
    #[error("no stable CLI release was found")]
    NoRelease,
    #[error("cannot determine the current executable directory: {0}")]
    ExecutableDirectory(std::io::Error),
    #[error(
        "selected version {selected} is not newer than {current}; use --force to reinstall or downgrade"
    )]
    NotNewer { current: Version, selected: Version },
    #[error("failed to start the installer: {0}")]
    InstallerStart(std::io::Error),
    #[error("failed to prepare the bundled installer: {0}")]
    InstallerInput(std::io::Error),
    #[error("installer exited with {status}: {stderr}")]
    InstallerFailed { status: String, stderr: String },
    #[cfg(windows)]
    #[error("timed out while waiting for the Windows installer to stage the update")]
    InstallerTimeout,
    #[error(transparent)]
    Output(#[from] std::io::Error),
}

#[derive(Debug, Deserialize)]
struct Release {
    tag_name: String,
    draft: bool,
    prerelease: bool,
}

pub fn run(args: &UpdateArgs) -> Result<(), UpdateError> {
    let current = Version::parse(env!("CARGO_PKG_VERSION")).expect("package version is SemVer");
    let selected = match &args.version {
        Some(value) => parse_release_version(value)?,
        None => latest_stable_release()?,
    };
    let install_dir = match &args.install_dir {
        Some(path) => path.clone(),
        None => current_executable_directory()?,
    };

    if !args.force && selected <= current {
        if selected == current {
            println!("owlauth {current} is already installed");
            return Ok(());
        }
        return Err(UpdateError::NotNewer { current, selected });
    }

    println!("owlauth {current} -> {selected}");
    println!("install directory: {}", install_dir.display());
    if args.dry_run {
        println!("status: dry-run");
        return Ok(());
    }

    run_installer(&selected, &install_dir)?;
    #[cfg(windows)]
    println!("update staged; replacement will finish after this process exits");
    #[cfg(not(windows))]
    println!("updated owlauth to {selected}");
    Ok(())
}

fn parse_release_version(value: &str) -> Result<Version, UpdateError> {
    let normalized = value
        .strip_prefix(RELEASE_TAG_PREFIX)
        .or_else(|| value.strip_prefix('v'))
        .unwrap_or(value);
    Version::parse(normalized).map_err(|source| UpdateError::InvalidVersion {
        value: value.to_owned(),
        source,
    })
}

fn latest_stable_release() -> Result<Version, UpdateError> {
    let repository =
        env::var("OWLAUTH_GITHUB_REPO").unwrap_or_else(|_| DEFAULT_REPOSITORY.to_owned());
    let api_base =
        env::var("OWLAUTH_GITHUB_API_URL").unwrap_or_else(|_| "https://api.github.com".to_owned());
    let url = format!(
        "{}/repos/{repository}/releases?per_page=100",
        api_base.trim_end_matches('/')
    );
    let response = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .map_err(|error| UpdateError::ReleaseQuery(error.to_string()))?
        .get(url)
        .header(reqwest::header::USER_AGENT, "owlauth-cli")
        .send()
        .and_then(reqwest::blocking::Response::error_for_status)
        .map_err(|error| UpdateError::ReleaseQuery(error.to_string()))?;
    let releases = response
        .json::<Vec<Release>>()
        .map_err(|error| UpdateError::ReleaseQuery(error.to_string()))?;
    select_latest_stable(&releases).ok_or(UpdateError::NoRelease)
}

fn select_latest_stable(releases: &[Release]) -> Option<Version> {
    releases
        .iter()
        .filter(|release| !release.draft && !release.prerelease)
        .filter_map(|release| {
            let value = release.tag_name.strip_prefix(RELEASE_TAG_PREFIX)?;
            Version::parse(value).ok()
        })
        .filter(|version| version.pre.is_empty())
        .max()
}

fn current_executable_directory() -> Result<PathBuf, UpdateError> {
    let executable = env::current_exe().map_err(UpdateError::ExecutableDirectory)?;
    executable.parent().map(Path::to_path_buf).ok_or_else(|| {
        UpdateError::ExecutableDirectory(std::io::Error::other("the executable path has no parent"))
    })
}

#[cfg(not(windows))]
fn run_installer(version: &Version, install_dir: &Path) -> Result<(), UpdateError> {
    let mut child = installer_command("sh", version, install_dir)
        .spawn()
        .map_err(UpdateError::InstallerStart)?;
    child
        .stdin
        .take()
        .expect("installer stdin is piped")
        .write_all(INSTALLER_SH.as_bytes())
        .map_err(UpdateError::InstallerInput)?;
    let output = child
        .wait_with_output()
        .map_err(UpdateError::InstallerStart)?;
    if output.status.success() {
        print!("{}", String::from_utf8_lossy(&output.stdout));
        Ok(())
    } else {
        Err(UpdateError::InstallerFailed {
            status: output.status.to_string(),
            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
        })
    }
}

#[cfg(windows)]
fn run_installer(version: &Version, install_dir: &Path) -> Result<(), UpdateError> {
    let process_id = std::process::id();
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let temporary_directory = env::temp_dir();
    let basename = format!("owlauth-update-{process_id}-{nonce}");
    let ready_file = temporary_directory.join(format!("{basename}.ready"));
    let script_file = temporary_directory.join(format!("{basename}.ps1"));
    std::fs::write(&script_file, INSTALLER_PS1).map_err(UpdateError::InstallerInput)?;

    let child = powershell_file_command(&script_file)
        .env("OWLAUTH_VERSION", version.to_string())
        .env("OWLAUTH_INSTALL_DIR", install_dir)
        .env("OWLAUTH_UPDATER_PID", process_id.to_string())
        .env("OWLAUTH_UPDATE_READY_FILE", &ready_file)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .spawn();
    let mut child = match child {
        Ok(child) => child,
        Err(error) => {
            let _ = std::fs::remove_file(&script_file);
            return Err(UpdateError::InstallerStart(error));
        }
    };

    for _ in 0..600 {
        if ready_file.is_file() {
            remove_file_if_present(&ready_file)?;
            let _ = std::fs::remove_file(&script_file);
            return Ok(());
        }
        if let Some(status) = child.try_wait().map_err(UpdateError::InstallerStart)? {
            let _ = std::fs::remove_file(&script_file);
            return Err(UpdateError::InstallerFailed {
                status: status.to_string(),
                stderr: "the installer exited before staging the replacement".to_owned(),
            });
        }
        std::thread::sleep(Duration::from_millis(100));
    }

    let _ = child.kill();
    let _ = child.wait();
    let _ = std::fs::remove_file(&script_file);
    Err(UpdateError::InstallerTimeout)
}

#[cfg(windows)]
fn remove_file_if_present(path: &Path) -> Result<(), UpdateError> {
    match std::fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(UpdateError::InstallerStart(error)),
    }
}

#[cfg(windows)]
fn powershell_file_command(script_file: &Path) -> Command {
    let mut command = Command::new("powershell");
    command
        .args([
            "-NoProfile",
            "-NonInteractive",
            "-ExecutionPolicy",
            "Bypass",
            "-File",
        ])
        .arg(script_file)
        .stdin(Stdio::null());
    command
}

#[cfg(not(windows))]
fn installer_command(program: &str, version: &Version, install_dir: &Path) -> Command {
    let mut command = Command::new(program);
    command
        .env("OWLAUTH_VERSION", version.to_string())
        .env("OWLAUTH_INSTALL_DIR", install_dir)
        .stdin(Stdio::piped());
    command
}

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

    fn release(tag_name: &str, draft: bool, prerelease: bool) -> Release {
        Release {
            tag_name: tag_name.to_owned(),
            draft,
            prerelease,
        }
    }

    #[test]
    fn parses_plain_and_prefixed_versions() {
        assert_eq!(
            parse_release_version("0.0.2").unwrap(),
            Version::new(0, 0, 2)
        );
        assert_eq!(
            parse_release_version("v0.0.2").unwrap(),
            Version::new(0, 0, 2)
        );
        assert_eq!(
            parse_release_version("cli-v0.0.2").unwrap(),
            Version::new(0, 0, 2)
        );
        assert!(parse_release_version("latest").is_err());
    }

    #[test]
    fn selects_highest_stable_cli_release() {
        let releases = vec![
            release("server-v9.0.0", false, false),
            release("cli-v0.0.2", false, false),
            release("cli-v0.0.4", true, false),
            release("cli-v0.0.3-rc.1", false, true),
            release("cli-v0.0.3", false, false),
        ];

        assert_eq!(select_latest_stable(&releases), Some(Version::new(0, 0, 3)));
    }

    #[cfg(windows)]
    #[test]
    fn powershell_file_mode_executes_the_complete_script() {
        let directory =
            env::temp_dir().join(format!("owlauth-powershell-test-{}", std::process::id()));
        let script_file = directory.join("installer.ps1");
        let marker_file = directory.join("marker");
        let _ = std::fs::remove_dir_all(&directory);
        std::fs::create_dir_all(&directory).unwrap();
        std::fs::write(
            &script_file,
            r#"$ErrorActionPreference = "Stop"
function Write-Marker {
    Set-Content -LiteralPath $env:OWLAUTH_TEST_MARKER -Value "ready" -NoNewline
}
Write-Marker
"#,
        )
        .unwrap();

        let status = powershell_file_command(&script_file)
            .env("OWLAUTH_TEST_MARKER", &marker_file)
            .status()
            .unwrap();

        assert!(status.success());
        assert_eq!(std::fs::read_to_string(&marker_file).unwrap(), "ready");
        std::fs::remove_dir_all(directory).unwrap();
    }
}