Skip to main content

kasl/libs/
update.rs

1//! Self-update from GitHub releases.
2//!
3//! ```rust,no_run
4//! use kasl::libs::update::Updater;
5//!
6//! #[tokio::main]
7//! async fn main() -> anyhow::Result<()> {
8//!     let mut updater = Updater::new()?;
9//!
10//!     if updater.check_for_latest_release().await? {
11//!         updater.perform_update().await?;
12//!     }
13//!
14//!     Ok(())
15//! }
16//! ```
17
18use crate::libs::data_storage::DataStorage;
19use crate::libs::messages::Message;
20use crate::{msg_bail_anyhow, msg_error_anyhow, msg_info};
21use anyhow::Result;
22use chrono::{DateTime, Duration, Utc};
23use flate2::read::GzDecoder;
24use reqwest::Client;
25use std::env;
26use std::fs::{self, File};
27use std::path::{Path, PathBuf};
28use tar::Archive;
29
30// Include application metadata (name, version, owner) generated at build time.
31include!(concat!(env!("OUT_DIR"), "/app_metadata.rs"));
32
33/// Cache file holding the timestamp of the last update check.
34const LAST_CHECK_FILE: &str = ".last_update_check";
35
36/// Minimum days between startup update checks.
37const DAILY_CHECK_INTERVAL: i64 = 1;
38
39/// Extension the replaced executable is kept under (`kasl.bak`).
40const BACKUP_EXTENSION: &str = "bak";
41
42/// The update workflow: check the latest tag, download the platform asset,
43/// swap the binary keeping the old one as `.bak`.
44#[derive(Debug)]
45pub struct Updater {
46    pub client: Client,
47
48    /// Repository owner, from build-time metadata.
49    pub owner: String,
50
51    /// Repository/app name, from build-time metadata.
52    pub name: String,
53
54    /// Version of the running binary.
55    pub version: String,
56
57    /// Newer version found by the check, if any.
58    pub latest_version: Option<String>,
59
60    /// Asset URL for this platform, set when a newer version is found.
61    pub download_url: Option<String>,
62
63    /// URL of the repository's `releases/latest` page.
64    ///
65    /// The latest tag is read from this page's redirect `Location` header
66    /// instead of `api.github.com`: the API allows only 60 anonymous
67    /// requests per hour per IP, which starves every machine behind a
68    /// shared NAT (the same failure the installers hit).
69    releases_url: String,
70
71    /// Path of the check-throttling timestamp file.
72    last_check_file: PathBuf,
73}
74
75impl Updater {
76    /// Builds an updater from build-time metadata.
77    ///
78    /// ```rust,no_run
79    /// # fn f() -> anyhow::Result<()> {
80    /// use kasl::libs::update::Updater;
81    ///
82    /// let updater = Updater::new()?;
83    /// println!("Updater configured for {} v{}", updater.name, updater.version);
84    /// # Ok(())
85    /// # }
86    /// ```
87    pub fn new() -> Result<Self> {
88        let owner = APP_METADATA_OWNER.to_owned();
89        let name = APP_METADATA_NAME.to_owned();
90
91        let last_check_file = DataStorage::new().get_path(LAST_CHECK_FILE)?;
92
93        // Release page whose redirect reveals the latest tag (no API quota)
94        let releases_url = format!("https://github.com/{}/{}/releases/latest", owner, name);
95
96        Ok(Self {
97            client: Client::new(),
98            owner,
99            name,
100            version: APP_METADATA_VERSION.to_owned(),
101            latest_version: None,
102            download_url: None,
103            last_check_file,
104            releases_url,
105        })
106    }
107
108    /// Prints an update notice when one is available - throttled to one
109    /// check per day, and silent on any failure, so startup never blocks
110    /// or complains because of the network.
111    ///
112    /// ```rust,no_run
113    /// # async fn f() {
114    /// use kasl::libs::update::Updater;
115    ///
116    /// // Call during application startup
117    /// Updater::show_update_notification().await;
118    /// # }
119    /// ```
120    pub async fn show_update_notification() {
121        let mut updater = match Self::new() {
122            Ok(up) => up,
123            Err(_) => return,
124        };
125
126        if !updater.is_check_due() {
127            return;
128        }
129
130        if let Ok(true) = updater.check_for_latest_release().await
131            && let Some(latest_version) = &updater.latest_version
132        {
133            msg_info!(
134                Message::UpdateAvailable {
135                    app_name: updater.name,
136                    latest: latest_version.to_string()
137                },
138                true // Show with extra spacing for visibility
139            )
140        }
141    }
142
143    /// Downloads the release archive and swaps the binary in.
144    ///
145    /// Requires a prior successful [`Updater::check_for_latest_release`]
146    /// (it sets `download_url`). The old executable stays next to the new
147    /// one as `.bak` - restoring it is a manual copy, nothing automatic.
148    ///
149    /// ```rust,no_run
150    /// # async fn f() -> anyhow::Result<()> {
151    /// use kasl::libs::update::Updater;
152    ///
153    /// let mut updater = Updater::new()?;
154    /// if updater.check_for_latest_release().await? {
155    ///     updater.perform_update().await?;
156    ///     println!("Update completed successfully");
157    /// }
158    /// # Ok(())
159    /// # }
160    /// ```
161    pub async fn perform_update(&self) -> Result<()> {
162        let download_url = self.download_url.as_ref().ok_or(msg_error_anyhow!(Message::UpdateDownloadUrlNotSet))?;
163
164        let response = self.client.get(download_url).send().await?;
165        let content = response.bytes().await?;
166
167        let tar_gz_path = env::temp_dir().join(format!("{}.tar.gz", self.name));
168        fs::write(&tar_gz_path, &content)?;
169
170        self.extract_and_replace_binary(&tar_gz_path)?;
171
172        fs::remove_file(&tar_gz_path)?;
173
174        Ok(())
175    }
176
177    /// Compares the latest published tag against the running version;
178    /// on a newer one, stores it and the platform asset URL.
179    ///
180    /// ```rust,no_run
181    /// # async fn f() -> anyhow::Result<()> {
182    /// use kasl::libs::update::Updater;
183    ///
184    /// let mut updater = Updater::new()?;
185    /// if updater.check_for_latest_release().await? {
186    ///     println!("Update available: {} -> {}",
187    ///         updater.version,
188    ///         updater.latest_version.unwrap());
189    /// }
190    /// # Ok(())
191    /// # }
192    /// ```
193    pub async fn check_for_latest_release(&mut self) -> Result<bool> {
194        let tag = self.fetch_latest_tag().await?;
195
196        self.update_last_check_time();
197
198        let latest_version = tag.trim_start_matches('v').to_string();
199
200        // String comparison; adequate for this project's version scheme.
201        if latest_version > self.version {
202            // Asset names follow the release convention: {name}-{tag}-{platform}.tar.gz
203            self.download_url = Some(format!(
204                "https://github.com/{}/{}/releases/download/{}/{}-{}-{}.tar.gz",
205                self.owner,
206                self.name,
207                tag,
208                self.name,
209                tag,
210                self.get_platform_identifier()
211            ));
212            self.latest_version = Some(latest_version);
213
214            Ok(true)
215        } else {
216            Ok(false)
217        }
218    }
219
220    /// Reads the latest release tag from the `releases/latest` redirect.
221    ///
222    /// GitHub answers this page with a `302` to `.../releases/tag/<tag>`;
223    /// the tag is taken from the `Location` header. Unlike `api.github.com`,
224    /// this endpoint has no anonymous rate limit, so it keeps working for
225    /// every machine behind a shared NAT.
226    async fn fetch_latest_tag(&self) -> Result<String> {
227        // The shared client follows redirects (needed for asset downloads),
228        // so the redirect probe uses its own non-following client.
229        let client = Client::builder().redirect(reqwest::redirect::Policy::none()).build()?;
230        let response = client.get(&self.releases_url).header("User-Agent", &self.name).send().await?;
231
232        let location = response
233            .headers()
234            .get(reqwest::header::LOCATION)
235            .and_then(|value| value.to_str().ok())
236            .ok_or_else(|| msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone())))?;
237
238        match location.rsplit_once("/releases/tag/") {
239            Some((_, tag)) if !tag.is_empty() => Ok(tag.to_string()),
240            _ => Err(msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone()))),
241        }
242    }
243
244    /// Unpacks the release archive over the installed binaries.
245    ///
246    /// Only the executables are taken: `kasl` (renamed to `.bak` first, so a
247    /// broken update can be undone by hand) and, when the alias sits next to
248    /// it, `ka`. LICENSE and README are skipped - copying them used to
249    /// recreate the archive's `kasl-<tag>-<target>/` prefix inside the
250    /// installation directory, leaving a folder of stale duplicates behind
251    /// after every update.
252    fn extract_and_replace_binary(&self, tar_gz_path: &PathBuf) -> Result<()> {
253        let current_exe = env::current_exe()?;
254        let install_dir = current_exe.parent().unwrap().to_path_buf();
255
256        Self::unpack_binaries(tar_gz_path, &install_dir, &self.name)
257    }
258
259    /// Replaces the binaries in `install_dir` from the archive.
260    ///
261    /// Split out from [`Updater::extract_and_replace_binary`] so the layout
262    /// rules can be tested against a real archive without a real update:
263    /// both release bugs found in the field (the alias missing, the leftover
264    /// version folders) lived here, untested.
265    pub(crate) fn unpack_binaries(tar_gz_path: &PathBuf, install_dir: &Path, app_name: &str) -> Result<()> {
266        // The app updates under its own name, not under whichever name was
267        // typed: `ka update` must still replace `kasl`.
268        let exe_suffix = env::consts::EXE_SUFFIX;
269        let primary = format!("{}{}", app_name, exe_suffix);
270        let alias = format!("ka{}", exe_suffix);
271
272        let tar_gz = File::open(tar_gz_path)?;
273        let tar = GzDecoder::new(tar_gz);
274        let mut archive = Archive::new(tar);
275        let mut is_updated = false;
276
277        for entry_result in archive.entries()? {
278            let mut entry = entry_result?;
279            let entry_path = entry.path()?.to_path_buf();
280            let Some(file_name) = entry_path.file_name().and_then(|name| name.to_str()) else {
281                continue;
282            };
283
284            // Flattened on purpose: archive entries carry a
285            // `kasl-<tag>-<target>/` prefix that must not reach the
286            // installation directory.
287            if file_name == primary {
288                let target = install_dir.join(&primary);
289                // Keep the replaced binary as the one-and-only backup.
290                if target.exists() {
291                    fs::rename(&target, target.with_extension(BACKUP_EXTENSION))?;
292                }
293                entry.unpack(&target)?;
294                is_updated = true;
295            } else if file_name == alias {
296                let target = install_dir.join(&alias);
297                // The alias is refreshed only where it is already installed:
298                // updating must not add a binary the user declined
299                // (`KASL_NO_ALIAS`), but a `ka` left behind at an older
300                // version would be worse than none at all.
301                if target.exists() {
302                    fs::remove_file(&target)?;
303                    entry.unpack(&target)?;
304                }
305            }
306        }
307
308        if is_updated {
309            Ok(())
310        } else {
311            msg_bail_anyhow!(Message::UpdateBinaryNotFoundInArchive);
312        }
313    }
314
315    /// Target triple used in release asset names, e.g.
316    /// `x86_64-pc-windows-msvc`, `aarch64-apple-darwin`,
317    /// `x86_64-unknown-linux-gnu`.
318    fn get_platform_identifier(&self) -> String {
319        let arch = env::consts::ARCH;
320        let os = match env::consts::OS {
321            "windows" => "pc-windows-msvc",
322            "macos" => "apple-darwin",
323            // Must match the published asset triple; releases ship glibc
324            // builds (the installers hit 404s on the old musl guess).
325            _ => "unknown-linux-gnu",
326        };
327
328        format!("{}-{}", arch, os)
329    }
330
331    /// Stamps the throttle file; write errors are ignored on purpose -
332    /// throttling is a convenience, and a failed write only means one
333    /// extra check later.
334    fn update_last_check_time(&self) {
335        let now = Utc::now().to_rfc3339();
336        let _ = fs::write(&self.last_check_file, now);
337    }
338
339    /// True when the daily check interval has passed. Fails open: a
340    /// missing or unreadable stamp allows the check rather than blocking
341    /// updates forever.
342    fn is_check_due(&self) -> bool {
343        match fs::read_to_string(&self.last_check_file) {
344            Ok(content) => {
345                let last_check = content
346                    .parse::<DateTime<Utc>>()
347                    .unwrap_or_else(|_| Utc::now() - Duration::days(DAILY_CHECK_INTERVAL + 1));
348
349                Utc::now().signed_duration_since(last_check) > Duration::days(DAILY_CHECK_INTERVAL)
350            }
351            Err(_) => true,
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use flate2::Compression;
360    use flate2::write::GzEncoder;
361    use tempfile::TempDir;
362
363    /// Builds an archive shaped like a real release asset: every entry sits
364    /// under a `kasl-<tag>-<target>/` directory, next to LICENSE and README.
365    fn release_archive(dir: &Path, files: &[(&str, &str)]) -> PathBuf {
366        let path = dir.join("release.tar.gz");
367        let encoder = GzEncoder::new(File::create(&path).unwrap(), Compression::default());
368        let mut builder = tar::Builder::new(encoder);
369
370        for (name, contents) in files {
371            let mut header = tar::Header::new_gnu();
372            header.set_size(contents.len() as u64);
373            header.set_mode(0o755);
374            header.set_cksum();
375            builder
376                .append_data(&mut header, format!("kasl-v9.9.9-x86_64-pc-windows-msvc/{name}"), contents.as_bytes())
377                .unwrap();
378        }
379
380        builder.into_inner().unwrap().finish().unwrap();
381        path
382    }
383
384    fn exe(name: &str) -> String {
385        format!("{}{}", name, env::consts::EXE_SUFFIX)
386    }
387
388    #[test]
389    fn the_archive_directory_prefix_stays_out_of_the_installation() {
390        // Field report, 14.08: every update left a `kasl-v1.2.0/` folder with
391        // copies of LICENSE and README next to the binary, because non-binary
392        // entries were unpacked under their in-archive path.
393        let temp = TempDir::new().unwrap();
394        let install = temp.path().join("install");
395        fs::create_dir(&install).unwrap();
396        fs::write(install.join(exe("kasl")), "old").unwrap();
397
398        let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), ("LICENSE", "MIT"), ("README.md", "docs")]);
399
400        Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
401
402        let leftovers: Vec<_> = fs::read_dir(&install)
403            .unwrap()
404            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
405            .filter(|name| name.starts_with("kasl-v"))
406            .collect();
407        assert!(leftovers.is_empty(), "update left {leftovers:?} in the installation directory");
408        assert!(!install.join("LICENSE").exists(), "LICENSE does not belong next to the binary");
409        assert!(!install.join("README.md").exists(), "README does not belong next to the binary");
410    }
411
412    #[test]
413    fn the_binary_is_replaced_and_the_old_one_kept_as_backup() {
414        let temp = TempDir::new().unwrap();
415        let install = temp.path().join("install");
416        fs::create_dir(&install).unwrap();
417        fs::write(install.join(exe("kasl")), "old").unwrap();
418
419        let archive = release_archive(temp.path(), &[(&exe("kasl"), "new")]);
420        Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
421
422        assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
423        assert_eq!(
424            fs::read_to_string(install.join("kasl.bak")).unwrap(),
425            "old",
426            "the replaced binary must remain recoverable"
427        );
428    }
429
430    #[test]
431    fn an_installed_alias_is_updated_together_with_the_binary() {
432        // A `ka` left at the previous version is a trap: it answers to the
433        // same commands while running older code.
434        let temp = TempDir::new().unwrap();
435        let install = temp.path().join("install");
436        fs::create_dir(&install).unwrap();
437        fs::write(install.join(exe("kasl")), "old").unwrap();
438        fs::write(install.join(exe("ka")), "old").unwrap();
439
440        let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "new")]);
441        Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
442
443        assert_eq!(fs::read_to_string(install.join(exe("ka"))).unwrap(), "new");
444    }
445
446    #[test]
447    fn an_absent_alias_is_not_installed_by_an_update() {
448        // `KASL_NO_ALIAS=1` at install time is a choice; an update must not
449        // quietly overturn it.
450        let temp = TempDir::new().unwrap();
451        let install = temp.path().join("install");
452        fs::create_dir(&install).unwrap();
453        fs::write(install.join(exe("kasl")), "old").unwrap();
454
455        let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "new")]);
456        Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
457
458        assert!(!install.join(exe("ka")).exists(), "the update added an alias the user never installed");
459    }
460
461    #[test]
462    fn an_archive_without_the_binary_fails_instead_of_reporting_success() {
463        let temp = TempDir::new().unwrap();
464        let install = temp.path().join("install");
465        fs::create_dir(&install).unwrap();
466
467        let archive = release_archive(temp.path(), &[("LICENSE", "MIT")]);
468        assert!(Updater::unpack_binaries(&archive, &install, "kasl").is_err());
469    }
470}