1use 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
18const REPO: &str = "timhartmann7/omnyssh";
20const HTTP_TIMEOUT: Duration = Duration::from_secs(8);
22const BUILD_TARGET: &str = env!("BUILD_TARGET");
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum InstallMethod {
33 Manual,
35 Homebrew,
36 Cargo,
37 Nix,
38}
39
40impl InstallMethod {
41 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 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 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#[derive(Debug, Clone)]
81pub struct UpdateInfo {
82 pub current: String,
84 pub latest: String,
86 pub tag: String,
88 pub method: InstallMethod,
90 pub can_self_update: bool,
92}
93
94impl UpdateInfo {
95 pub fn release_url(&self) -> String {
97 format!("https://github.com/{REPO}/releases/tag/{}", self.tag)
98 }
99}
100
101pub 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
129fn 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#[derive(serde::Deserialize)]
140struct GithubRelease {
141 tag_name: String,
142}
143
144async 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
160pub 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
187fn 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
218fn 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
238fn 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
259fn 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
280fn 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#[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 assert!(!is_newer("not-a-version", "1.0.1"));
348 assert!(!is_newer("1.0.2", "garbage"));
349 }
350
351 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 assert!(verify_checksum(b"tampered", &sums, "omny-x86_64-apple-darwin.tar.gz").is_err());
392 assert!(verify_checksum(archive, &sums, "omny-missing.tar.gz").is_err());
394 }
395}