Skip to main content

omnyssh_core/
update.rs

1//! In-app update checker.
2//!
3//! On startup OmnySSH queries the GitHub Releases API for the latest version.
4//! Depending on how the binary was installed it can either self-replace the
5//! executable (manual / `install.sh` installs on Linux and macOS) or show the
6//! package-manager command the user should run instead.
7
8use std::io::Read;
9use std::path::Path;
10use std::time::Duration;
11
12use anyhow::{Context, Result};
13use flate2::read::GzDecoder;
14use semver::Version;
15use sha2::{Digest, Sha256};
16use tar::Archive;
17
18/// GitHub repository that hosts OmnySSH releases.
19const REPO: &str = "timhartmann7/omnyssh";
20/// Timeout applied to every network request the updater makes.
21const HTTP_TIMEOUT: Duration = Duration::from_secs(8);
22/// Target triple this binary was built for (provided by `build.rs`).
23const BUILD_TARGET: &str = env!("BUILD_TARGET");
24
25// ---------------------------------------------------------------------------
26// Install method
27// ---------------------------------------------------------------------------
28
29/// How the running binary was installed. Determines whether an in-app
30/// self-update is possible.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum InstallMethod {
33    /// `install.sh` or a manually placed binary — eligible for self-update.
34    Manual,
35    Homebrew,
36    Cargo,
37    Nix,
38}
39
40impl InstallMethod {
41    /// Detects the install method of the currently running executable.
42    pub fn detect() -> Self {
43        match std::env::current_exe() {
44            Ok(path) => Self::from_path(&path),
45            Err(_) => Self::Manual,
46        }
47    }
48
49    /// Classifies an executable path by well-known install locations.
50    fn from_path(path: &Path) -> Self {
51        let p = path.to_string_lossy();
52        if p.contains("/nix/store/") {
53            Self::Nix
54        } else if p.contains("/Cellar/") || p.contains("/homebrew/") {
55            Self::Homebrew
56        } else if p.contains("/.cargo/") {
57            Self::Cargo
58        } else {
59            Self::Manual
60        }
61    }
62
63    /// Command the user runs to upgrade through this install method.
64    /// `None` means the app is able to perform the update itself.
65    pub fn upgrade_command(self) -> Option<&'static str> {
66        match self {
67            Self::Manual => None,
68            Self::Homebrew => Some("brew upgrade omnyssh"),
69            Self::Cargo => Some("cargo install omnyssh --force"),
70            Self::Nix => Some("nix profile upgrade omnyssh"),
71        }
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Update info
77// ---------------------------------------------------------------------------
78
79/// A newer release discovered by [`check`].
80#[derive(Debug, Clone)]
81pub struct UpdateInfo {
82    /// Version this binary reports (without a leading `v`).
83    pub current: String,
84    /// Latest released version (without a leading `v`).
85    pub latest: String,
86    /// Git tag of the latest release (e.g. `v1.0.2`).
87    pub tag: String,
88    /// How this binary was installed.
89    pub method: InstallMethod,
90    /// Whether the app can download and install this update itself.
91    pub can_self_update: bool,
92}
93
94impl UpdateInfo {
95    /// URL of the release page, shown when the app cannot self-update.
96    pub fn release_url(&self) -> String {
97        format!("https://github.com/{REPO}/releases/tag/{}", self.tag)
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Version check
103// ---------------------------------------------------------------------------
104
105/// Queries GitHub for the latest release. Returns `Some` only when a strictly
106/// newer version exists. Any network or parse error yields `None`, so a failed
107/// check never disrupts startup.
108pub async fn check() -> Option<UpdateInfo> {
109    let current = env!("CARGO_PKG_VERSION");
110    let tag = fetch_latest_tag().await.ok()?;
111    let latest = tag.trim_start_matches('v').to_string();
112
113    if !is_newer(&latest, current) {
114        return None;
115    }
116
117    let method = InstallMethod::detect();
118    Some(UpdateInfo {
119        current: current.to_string(),
120        latest,
121        tag,
122        method,
123        can_self_update: method == InstallMethod::Manual
124            && cfg!(any(target_os = "linux", target_os = "macos"))
125            && binary_is_replaceable(),
126    })
127}
128
129/// Returns `true` when `latest` is a strictly greater semver than `current`.
130/// An unparseable version yields `false` — never nag on bad data.
131fn is_newer(latest: &str, current: &str) -> bool {
132    match (Version::parse(latest), Version::parse(current)) {
133        (Ok(l), Ok(c)) => l > c,
134        _ => false,
135    }
136}
137
138/// Minimal subset of the GitHub release JSON payload.
139#[derive(serde::Deserialize)]
140struct GithubRelease {
141    tag_name: String,
142}
143
144/// Fetches the `tag_name` of the latest (non-prerelease) GitHub release.
145async fn fetch_latest_tag() -> Result<String> {
146    let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
147    let release: GithubRelease = http_client()?
148        .get(&url)
149        .send()
150        .await
151        .context("release request failed")?
152        .error_for_status()
153        .context("release request returned an error status")?
154        .json()
155        .await
156        .context("failed to decode release JSON")?;
157    Ok(release.tag_name)
158}
159
160// ---------------------------------------------------------------------------
161// Self-update
162// ---------------------------------------------------------------------------
163
164/// Downloads the release archive for [`BUILD_TARGET`], verifies its SHA-256
165/// against the release `SHA256SUMS`, and replaces the running executable.
166///
167/// Only valid when [`UpdateInfo::can_self_update`] is `true`.
168pub async fn perform_update(info: &UpdateInfo) -> Result<()> {
169    let archive_name = format!("omny-{BUILD_TARGET}.tar.gz");
170    let base = format!("https://github.com/{REPO}/releases/download/{}", info.tag);
171    let client = http_client()?;
172
173    let archive = download_bytes(&client, &format!("{base}/{archive_name}"))
174        .await
175        .context("failed to download release archive")?;
176    let sums = download_text(&client, &format!("{base}/SHA256SUMS"))
177        .await
178        .context("failed to download SHA256SUMS")?;
179
180    verify_checksum(&archive, &sums, &archive_name)?;
181
182    let binary = extract_binary(&archive).context("failed to extract binary from archive")?;
183    install_binary(&binary).context("failed to replace the running executable")?;
184    Ok(())
185}
186
187/// Builds the shared HTTP client. GitHub requires a `User-Agent` header.
188fn http_client() -> Result<reqwest::Client> {
189    reqwest::Client::builder()
190        .user_agent(concat!("omnyssh/", env!("CARGO_PKG_VERSION")))
191        .timeout(HTTP_TIMEOUT)
192        .build()
193        .context("failed to build HTTP client")
194}
195
196async fn download_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
197    let bytes = client
198        .get(url)
199        .send()
200        .await?
201        .error_for_status()?
202        .bytes()
203        .await?;
204    Ok(bytes.to_vec())
205}
206
207async fn download_text(client: &reqwest::Client, url: &str) -> Result<String> {
208    let text = client
209        .get(url)
210        .send()
211        .await?
212        .error_for_status()?
213        .text()
214        .await?;
215    Ok(text)
216}
217
218/// Verifies `archive` bytes against the `SHA256SUMS` entry for `archive_name`.
219fn verify_checksum(archive: &[u8], sums: &str, archive_name: &str) -> Result<()> {
220    let expected = sums
221        .lines()
222        .find_map(|line| {
223            let (hash, name) = line.split_once("  ")?;
224            (name.trim() == archive_name).then(|| hash.trim().to_lowercase())
225        })
226        .with_context(|| format!("no checksum entry for {archive_name}"))?;
227
228    let mut hasher = Sha256::new();
229    hasher.update(archive);
230    let actual = format!("{:x}", hasher.finalize());
231
232    if actual != expected {
233        anyhow::bail!("checksum mismatch: expected {expected}, got {actual}");
234    }
235    Ok(())
236}
237
238/// Extracts the `omny` binary from a gzip-compressed tar archive.
239fn extract_binary(archive: &[u8]) -> Result<Vec<u8>> {
240    let mut tar = Archive::new(GzDecoder::new(archive));
241    for entry in tar.entries().context("invalid tar archive")? {
242        let mut entry = entry.context("invalid tar entry")?;
243        let is_binary = entry
244            .path()
245            .ok()
246            .and_then(|p| p.file_name().map(|n| n.to_os_string()))
247            .is_some_and(|n| n == "omny");
248        if is_binary {
249            let mut buf = Vec::new();
250            entry
251                .read_to_end(&mut buf)
252                .context("failed to read binary")?;
253            return Ok(buf);
254        }
255    }
256    anyhow::bail!("archive does not contain the omny binary")
257}
258
259/// Writes `binary` to a temp file next to the current executable and
260/// atomically swaps it in.
261fn install_binary(binary: &[u8]) -> Result<()> {
262    let exe = std::env::current_exe().context("cannot locate current executable")?;
263    let dir = exe.parent().context("executable has no parent directory")?;
264    let tmp = dir.join(format!(".omny-update-{}", std::process::id()));
265
266    std::fs::write(&tmp, binary).with_context(|| format!("failed to write {}", tmp.display()))?;
267
268    #[cfg(unix)]
269    {
270        use std::os::unix::fs::PermissionsExt;
271        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
272            .context("failed to mark binary executable")?;
273    }
274
275    let result = self_replace::self_replace(&tmp).context("self-replace failed");
276    let _ = std::fs::remove_file(&tmp);
277    result
278}
279
280/// Checks whether the running executable's directory is writable by creating
281/// and removing a probe file. `self_replace` writes the replacement there
282/// before swapping it in, so a writable directory is required.
283fn binary_is_replaceable() -> bool {
284    let Ok(exe) = std::env::current_exe() else {
285        return false;
286    };
287    let Some(dir) = exe.parent() else {
288        return false;
289    };
290    let probe = dir.join(format!(".omny-update-probe-{}", std::process::id()));
291    match std::fs::File::create(&probe) {
292        Ok(_) => {
293            let _ = std::fs::remove_file(&probe);
294            true
295        }
296        Err(_) => false,
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Tests
302// ---------------------------------------------------------------------------
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::path::PathBuf;
308
309    #[test]
310    fn detects_install_method_from_path() {
311        let cases = [
312            ("/home/u/.cargo/bin/omny", InstallMethod::Cargo),
313            (
314                "/opt/homebrew/Cellar/omnyssh/1.0.1/bin/omny",
315                InstallMethod::Homebrew,
316            ),
317            ("/usr/local/homebrew/bin/omny", InstallMethod::Homebrew),
318            ("/nix/store/abc-omnyssh-1.0.1/bin/omny", InstallMethod::Nix),
319            ("/usr/local/bin/omny", InstallMethod::Manual),
320            ("/home/u/bin/omny", InstallMethod::Manual),
321        ];
322        for (path, expected) in cases {
323            assert_eq!(
324                InstallMethod::from_path(&PathBuf::from(path)),
325                expected,
326                "{path}"
327            );
328        }
329    }
330
331    #[test]
332    fn upgrade_command_only_for_package_managers() {
333        assert!(InstallMethod::Manual.upgrade_command().is_none());
334        assert!(InstallMethod::Homebrew.upgrade_command().is_some());
335        assert!(InstallMethod::Cargo.upgrade_command().is_some());
336        assert!(InstallMethod::Nix.upgrade_command().is_some());
337    }
338
339    #[test]
340    fn is_newer_compares_semver() {
341        assert!(is_newer("1.0.2", "1.0.1"));
342        assert!(is_newer("1.1.0", "1.0.9"));
343        assert!(is_newer("2.0.0", "1.9.9"));
344        assert!(!is_newer("1.0.1", "1.0.1"));
345        assert!(!is_newer("1.0.0", "1.0.1"));
346        // Unparseable versions never trigger an update prompt.
347        assert!(!is_newer("not-a-version", "1.0.1"));
348        assert!(!is_newer("1.0.2", "garbage"));
349    }
350
351    /// Builds a gzip-compressed tar archive containing the given files.
352    fn make_targz(files: &[(&str, &[u8])]) -> Vec<u8> {
353        use flate2::write::GzEncoder;
354        use flate2::Compression;
355
356        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
357        for (name, data) in files {
358            let mut header = tar::Header::new_gnu();
359            header.set_size(data.len() as u64);
360            header.set_mode(0o755);
361            header.set_cksum();
362            builder.append_data(&mut header, name, *data).unwrap();
363        }
364        builder.into_inner().unwrap().finish().unwrap()
365    }
366
367    #[test]
368    fn extracts_omny_binary_from_archive() {
369        let archive = make_targz(&[("omny", b"fake-binary-bytes")]);
370        let binary = extract_binary(&archive).expect("extract");
371        assert_eq!(binary, b"fake-binary-bytes");
372    }
373
374    #[test]
375    fn extract_fails_when_binary_absent() {
376        let archive = make_targz(&[("readme.txt", b"hello")]);
377        assert!(extract_binary(&archive).is_err());
378    }
379
380    #[test]
381    fn checksum_verification() {
382        let archive = b"release archive contents";
383        let mut hasher = Sha256::new();
384        hasher.update(archive);
385        let digest = format!("{:x}", hasher.finalize());
386
387        let sums = format!("{digest}  omny-x86_64-apple-darwin.tar.gz\nffff  omny-other.tar.gz\n");
388        assert!(verify_checksum(archive, &sums, "omny-x86_64-apple-darwin.tar.gz").is_ok());
389
390        // Wrong contents fail.
391        assert!(verify_checksum(b"tampered", &sums, "omny-x86_64-apple-darwin.tar.gz").is_err());
392        // Missing entry fails.
393        assert!(verify_checksum(archive, &sums, "omny-missing.tar.gz").is_err());
394    }
395}