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 {
#[arg(long, value_name = "SEMVER")]
version: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
force: bool,
#[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();
}
}