use std::io::Read;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use semver::Version;
use sha2::{Digest, Sha256};
use tar::Archive;
const REPO: &str = "timhartmann7/omnyssh";
const HTTP_TIMEOUT: Duration = Duration::from_secs(8);
const BUILD_TARGET: &str = env!("BUILD_TARGET");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMethod {
Manual,
Homebrew,
Cargo,
Nix,
}
impl InstallMethod {
pub fn detect() -> Self {
match std::env::current_exe() {
Ok(path) => Self::from_path(&path),
Err(_) => Self::Manual,
}
}
fn from_path(path: &Path) -> Self {
let p = path.to_string_lossy();
if p.contains("/nix/store/") {
Self::Nix
} else if p.contains("/Cellar/") || p.contains("/homebrew/") {
Self::Homebrew
} else if p.contains("/.cargo/") {
Self::Cargo
} else {
Self::Manual
}
}
pub fn upgrade_command(self) -> Option<&'static str> {
match self {
Self::Manual => None,
Self::Homebrew => Some("brew upgrade omnyssh"),
Self::Cargo => Some("cargo install omnyssh --force"),
Self::Nix => Some("nix profile upgrade omnyssh"),
}
}
}
#[derive(Debug, Clone)]
pub struct UpdateInfo {
pub current: String,
pub latest: String,
pub tag: String,
pub method: InstallMethod,
pub can_self_update: bool,
}
impl UpdateInfo {
pub fn release_url(&self) -> String {
format!("https://github.com/{REPO}/releases/tag/{}", self.tag)
}
}
pub async fn check() -> Option<UpdateInfo> {
let current = env!("CARGO_PKG_VERSION");
let tag = fetch_latest_tag().await.ok()?;
let latest = tag.trim_start_matches('v').to_string();
if !is_newer(&latest, current) {
return None;
}
let method = InstallMethod::detect();
Some(UpdateInfo {
current: current.to_string(),
latest,
tag,
method,
can_self_update: method == InstallMethod::Manual
&& cfg!(any(target_os = "linux", target_os = "macos"))
&& binary_is_replaceable(),
})
}
fn is_newer(latest: &str, current: &str) -> bool {
match (Version::parse(latest), Version::parse(current)) {
(Ok(l), Ok(c)) => l > c,
_ => false,
}
}
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
}
async fn fetch_latest_tag() -> Result<String> {
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let release: GithubRelease = http_client()?
.get(&url)
.send()
.await
.context("release request failed")?
.error_for_status()
.context("release request returned an error status")?
.json()
.await
.context("failed to decode release JSON")?;
Ok(release.tag_name)
}
pub async fn perform_update(info: &UpdateInfo) -> Result<()> {
let archive_name = format!("omny-{BUILD_TARGET}.tar.gz");
let base = format!("https://github.com/{REPO}/releases/download/{}", info.tag);
let client = http_client()?;
let archive = download_bytes(&client, &format!("{base}/{archive_name}"))
.await
.context("failed to download release archive")?;
let sums = download_text(&client, &format!("{base}/SHA256SUMS"))
.await
.context("failed to download SHA256SUMS")?;
verify_checksum(&archive, &sums, &archive_name)?;
let binary = extract_binary(&archive).context("failed to extract binary from archive")?;
install_binary(&binary).context("failed to replace the running executable")?;
Ok(())
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(concat!("omnyssh/", env!("CARGO_PKG_VERSION")))
.timeout(HTTP_TIMEOUT)
.build()
.context("failed to build HTTP client")
}
async fn download_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
let bytes = client
.get(url)
.send()
.await?
.error_for_status()?
.bytes()
.await?;
Ok(bytes.to_vec())
}
async fn download_text(client: &reqwest::Client, url: &str) -> Result<String> {
let text = client
.get(url)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(text)
}
fn verify_checksum(archive: &[u8], sums: &str, archive_name: &str) -> Result<()> {
let expected = sums
.lines()
.find_map(|line| {
let (hash, name) = line.split_once(" ")?;
(name.trim() == archive_name).then(|| hash.trim().to_lowercase())
})
.with_context(|| format!("no checksum entry for {archive_name}"))?;
let mut hasher = Sha256::new();
hasher.update(archive);
let actual = format!("{:x}", hasher.finalize());
if actual != expected {
anyhow::bail!("checksum mismatch: expected {expected}, got {actual}");
}
Ok(())
}
fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
let mut tar = Archive::new(GzDecoder::new(archive));
for entry in tar.entries().context("invalid tar archive")? {
let mut entry = entry.context("invalid tar entry")?;
let is_binary = entry
.path()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_os_string()))
.is_some_and(|n| n == "omny");
if is_binary {
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.context("failed to read binary")?;
return Ok(buf);
}
}
anyhow::bail!("archive does not contain the omny binary")
}
fn install_binary(binary: &[u8]) -> Result<()> {
let exe = std::env::current_exe().context("cannot locate current executable")?;
let dir = exe.parent().context("executable has no parent directory")?;
let tmp = dir.join(format!(".omny-update-{}", std::process::id()));
std::fs::write(&tmp, binary).with_context(|| format!("failed to write {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.context("failed to mark binary executable")?;
}
let result = self_replace::self_replace(&tmp).context("self-replace failed");
let _ = std::fs::remove_file(&tmp);
result
}
fn binary_is_replaceable() -> bool {
let Ok(exe) = std::env::current_exe() else {
return false;
};
let Some(dir) = exe.parent() else {
return false;
};
let probe = dir.join(format!(".omny-update-probe-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn detects_install_method_from_path() {
let cases = [
("/home/u/.cargo/bin/omny", InstallMethod::Cargo),
(
"/opt/homebrew/Cellar/omnyssh/1.0.1/bin/omny",
InstallMethod::Homebrew,
),
("/usr/local/homebrew/bin/omny", InstallMethod::Homebrew),
("/nix/store/abc-omnyssh-1.0.1/bin/omny", InstallMethod::Nix),
("/usr/local/bin/omny", InstallMethod::Manual),
("/home/u/bin/omny", InstallMethod::Manual),
];
for (path, expected) in cases {
assert_eq!(
InstallMethod::from_path(&PathBuf::from(path)),
expected,
"{path}"
);
}
}
#[test]
fn upgrade_command_only_for_package_managers() {
assert!(InstallMethod::Manual.upgrade_command().is_none());
assert!(InstallMethod::Homebrew.upgrade_command().is_some());
assert!(InstallMethod::Cargo.upgrade_command().is_some());
assert!(InstallMethod::Nix.upgrade_command().is_some());
}
#[test]
fn is_newer_compares_semver() {
assert!(is_newer("1.0.2", "1.0.1"));
assert!(is_newer("1.1.0", "1.0.9"));
assert!(is_newer("2.0.0", "1.9.9"));
assert!(!is_newer("1.0.1", "1.0.1"));
assert!(!is_newer("1.0.0", "1.0.1"));
assert!(!is_newer("not-a-version", "1.0.1"));
assert!(!is_newer("1.0.2", "garbage"));
}
fn make_targz(files: &[(&str, &[u8])]) -> Vec<u8> {
use flate2::write::GzEncoder;
use flate2::Compression;
let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
for (name, data) in files {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append_data(&mut header, name, *data).unwrap();
}
builder.into_inner().unwrap().finish().unwrap()
}
#[test]
fn extracts_omny_binary_from_archive() {
let archive = make_targz(&[("omny", b"fake-binary-bytes")]);
let binary = extract_binary(&archive).expect("extract");
assert_eq!(binary, b"fake-binary-bytes");
}
#[test]
fn extract_fails_when_binary_absent() {
let archive = make_targz(&[("readme.txt", b"hello")]);
assert!(extract_binary(&archive).is_err());
}
#[test]
fn checksum_verification() {
let archive = b"release archive contents";
let mut hasher = Sha256::new();
hasher.update(archive);
let digest = format!("{:x}", hasher.finalize());
let sums = format!("{digest} omny-x86_64-apple-darwin.tar.gz\nffff omny-other.tar.gz\n");
assert!(verify_checksum(archive, &sums, "omny-x86_64-apple-darwin.tar.gz").is_ok());
assert!(verify_checksum(b"tampered", &sums, "omny-x86_64-apple-darwin.tar.gz").is_err());
assert!(verify_checksum(archive, &sums, "omny-missing.tar.gz").is_err());
}
}