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<crate::libs::alias::Outcome> {
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 let alias = self.extract_and_replace_binary(&tar_gz_path)?;
171
172 fs::remove_file(&tar_gz_path)?;
173
174 // Returned rather than printed here: a stale second name has to reach
175 // the user, and the command layer is what talks to them.
176 Ok(alias)
177 }
178
179 /// Compares the latest published tag against the running version;
180 /// on a newer one, stores it and the platform asset URL.
181 ///
182 /// ```rust,no_run
183 /// # async fn f() -> anyhow::Result<()> {
184 /// use kasl::libs::update::Updater;
185 ///
186 /// let mut updater = Updater::new()?;
187 /// if updater.check_for_latest_release().await? {
188 /// println!("Update available: {} -> {}",
189 /// updater.version,
190 /// updater.latest_version.unwrap());
191 /// }
192 /// # Ok(())
193 /// # }
194 /// ```
195 pub async fn check_for_latest_release(&mut self) -> Result<bool> {
196 let tag = self.fetch_latest_tag().await?;
197
198 self.update_last_check_time();
199
200 let latest_version = tag.trim_start_matches('v').to_string();
201
202 // String comparison; adequate for this project's version scheme.
203 if latest_version > self.version {
204 // Asset names follow the release convention: {name}-{tag}-{platform}.tar.gz
205 self.download_url = Some(format!(
206 "https://github.com/{}/{}/releases/download/{}/{}-{}-{}.tar.gz",
207 self.owner,
208 self.name,
209 tag,
210 self.name,
211 tag,
212 self.get_platform_identifier()
213 ));
214 self.latest_version = Some(latest_version);
215
216 Ok(true)
217 } else {
218 Ok(false)
219 }
220 }
221
222 /// Reads the latest release tag from the `releases/latest` redirect.
223 ///
224 /// GitHub answers this page with a `302` to `.../releases/tag/<tag>`;
225 /// the tag is taken from the `Location` header. Unlike `api.github.com`,
226 /// this endpoint has no anonymous rate limit, so it keeps working for
227 /// every machine behind a shared NAT.
228 async fn fetch_latest_tag(&self) -> Result<String> {
229 // The shared client follows redirects (needed for asset downloads),
230 // so the redirect probe uses its own non-following client.
231 let client = Client::builder().redirect(reqwest::redirect::Policy::none()).build()?;
232 let response = client.get(&self.releases_url).header("User-Agent", &self.name).send().await?;
233
234 let location = response
235 .headers()
236 .get(reqwest::header::LOCATION)
237 .and_then(|value| value.to_str().ok())
238 .ok_or_else(|| msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone())))?;
239
240 match location.rsplit_once("/releases/tag/") {
241 Some((_, tag)) if !tag.is_empty() => Ok(tag.to_string()),
242 _ => Err(msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone()))),
243 }
244 }
245
246 /// Deletes the executable a previous update left behind as `.bak`.
247 ///
248 /// Called on every command rather than from the update alone: after
249 /// updating, nobody has a reason to run the updater again, and the
250 /// leftover is a whole 15 MB binary that nothing ever comes back for. It
251 /// exists because Windows will not delete a running image - the outgoing
252 /// file is renamed aside instead - and it can only be removed once it is
253 /// no longer the running one, which is the next command.
254 ///
255 /// Best-effort: still locked means the next command tries again.
256 pub fn sweep_backup() {
257 let Ok(exe) = env::current_exe() else { return };
258 let _ = fs::remove_file(exe.with_extension(BACKUP_EXTENSION));
259 // The other name's leftover too: an update run as `ka` leaves
260 // `ka.bak`, and one run as `kasl` leaves `kasl.bak`.
261 if let Some(other) = crate::libs::alias::counterpart(&exe) {
262 let _ = fs::remove_file(other.with_extension(BACKUP_EXTENSION));
263 }
264 }
265
266 /// Unpacks the release archive over the installed binary.
267 ///
268 /// Only the executable is taken, and only the one named after the app:
269 /// LICENSE and README are skipped - copying them used to recreate the
270 /// archive's `kasl-<tag>-<target>/` prefix inside the installation
271 /// directory, leaving a folder of stale duplicates behind after every
272 /// update.
273 ///
274 /// The `ka` alias is not in the archive any more: it is a link to this
275 /// binary, so it needs re-pointing rather than replacing. That is
276 /// [`crate::libs::alias::refresh`], and it happens here because the swap is
277 /// what breaks the link.
278 ///
279 /// Running as the alias needs one extra step first. `ka` is a hard link to
280 /// `kasl`, so both names are the same file - and while `ka` is the running
281 /// image, Windows keeps those bytes alive under that name. Renaming
282 /// `kasl` aside then frees the *name* but not the *file*, the archive's
283 /// binary lands as a new `kasl`, and `ka` goes on answering with the
284 /// previous release. Caught on a live stand: an update run as `ka`
285 /// reported success while both names stayed on the old version - quieter,
286 /// and so worse, than the "access denied" it replaced.
287 ///
288 /// Moving the running name aside first is what breaks that: the swap then
289 /// starts from a directory where no name holds the outgoing file.
290 fn extract_and_replace_binary(&self, tar_gz_path: &PathBuf) -> Result<crate::libs::alias::Outcome> {
291 let current_exe = env::current_exe()?;
292 let install_dir = current_exe.parent().unwrap().to_path_buf();
293 let running_name = install_dir.join(current_exe.file_name().unwrap_or_default());
294
295 // Only when running under a name the swap will not replace itself -
296 // `unpack_binaries` already renames `kasl` aside.
297 let primary = install_dir.join(format!("{}{}", self.name, env::consts::EXE_SUFFIX));
298 if running_name != primary && running_name.exists() {
299 fs::rename(&running_name, running_name.with_extension(BACKUP_EXTENSION))?;
300 }
301
302 Self::unpack_binaries(tar_gz_path, &install_dir, &self.name)?;
303
304 // Re-point the name that is not the freshly unpacked one. After an
305 // update run as `ka` that name is gone (moved aside just above), so
306 // the link is created from scratch rather than refreshed.
307 if running_name != primary {
308 return Ok(match crate::libs::alias::link(&primary, &running_name) {
309 Ok(()) => crate::libs::alias::Outcome::Relinked(running_name),
310 Err(err) => crate::libs::alias::Outcome::Failed(running_name, err.to_string()),
311 });
312 }
313 Ok(crate::libs::alias::refresh(&primary))
314 }
315
316 /// Replaces the binaries in `install_dir` from the archive.
317 ///
318 /// Split out from [`Updater::extract_and_replace_binary`] so the layout
319 /// rules can be tested against a real archive without a real update:
320 /// both release bugs found in the field (the alias missing, the leftover
321 /// version folders) lived here, untested.
322 pub(crate) fn unpack_binaries(tar_gz_path: &PathBuf, install_dir: &Path, app_name: &str) -> Result<()> {
323 // The app updates under its own name, not under whichever name was
324 // typed: `ka self-update` must still replace `kasl`.
325 let exe_suffix = env::consts::EXE_SUFFIX;
326 let primary = format!("{}{}", app_name, exe_suffix);
327
328 let tar_gz = File::open(tar_gz_path)?;
329 let tar = GzDecoder::new(tar_gz);
330 let mut archive = Archive::new(tar);
331 let mut is_updated = false;
332
333 for entry_result in archive.entries()? {
334 let mut entry = entry_result?;
335 let entry_path = entry.path()?.to_path_buf();
336 let Some(file_name) = entry_path.file_name().and_then(|name| name.to_str()) else {
337 continue;
338 };
339
340 // Flattened on purpose: archive entries carry a
341 // `kasl-<tag>-<target>/` prefix that must not reach the
342 // installation directory.
343 if file_name == primary {
344 let target = install_dir.join(&primary);
345 // Keep the replaced binary as the one-and-only backup. A
346 // rename is allowed on a running image where a delete is not,
347 // which is what lets an update replace the file it is
348 // executing from.
349 if target.exists() {
350 fs::rename(&target, target.with_extension(BACKUP_EXTENSION))?;
351 }
352 entry.unpack(&target)?;
353 is_updated = true;
354 }
355 }
356
357 if is_updated {
358 Ok(())
359 } else {
360 msg_bail_anyhow!(Message::UpdateBinaryNotFoundInArchive);
361 }
362 }
363
364 /// Target triple used in release asset names, e.g.
365 /// `x86_64-pc-windows-msvc`, `aarch64-apple-darwin`,
366 /// `x86_64-unknown-linux-gnu`.
367 fn get_platform_identifier(&self) -> String {
368 let arch = env::consts::ARCH;
369 let os = match env::consts::OS {
370 "windows" => "pc-windows-msvc",
371 "macos" => "apple-darwin",
372 // Must match the published asset triple; releases ship glibc
373 // builds (the installers hit 404s on the old musl guess).
374 _ => "unknown-linux-gnu",
375 };
376
377 format!("{}-{}", arch, os)
378 }
379
380 /// Stamps the throttle file; write errors are ignored on purpose -
381 /// throttling is a convenience, and a failed write only means one
382 /// extra check later.
383 fn update_last_check_time(&self) {
384 let now = Utc::now().to_rfc3339();
385 let _ = fs::write(&self.last_check_file, now);
386 }
387
388 /// True when the daily check interval has passed. Fails open: a
389 /// missing or unreadable stamp allows the check rather than blocking
390 /// updates forever.
391 fn is_check_due(&self) -> bool {
392 match fs::read_to_string(&self.last_check_file) {
393 Ok(content) => {
394 let last_check = content
395 .parse::<DateTime<Utc>>()
396 .unwrap_or_else(|_| Utc::now() - Duration::days(DAILY_CHECK_INTERVAL + 1));
397
398 Utc::now().signed_duration_since(last_check) > Duration::days(DAILY_CHECK_INTERVAL)
399 }
400 Err(_) => true,
401 }
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use flate2::Compression;
409 use flate2::write::GzEncoder;
410 use tempfile::TempDir;
411
412 /// Builds an archive shaped like a real release asset: every entry sits
413 /// under a `kasl-<tag>-<target>/` directory, next to LICENSE and README.
414 fn release_archive(dir: &Path, files: &[(&str, &str)]) -> PathBuf {
415 let path = dir.join("release.tar.gz");
416 let encoder = GzEncoder::new(File::create(&path).unwrap(), Compression::default());
417 let mut builder = tar::Builder::new(encoder);
418
419 for (name, contents) in files {
420 let mut header = tar::Header::new_gnu();
421 header.set_size(contents.len() as u64);
422 header.set_mode(0o755);
423 header.set_cksum();
424 builder
425 .append_data(&mut header, format!("kasl-v9.9.9-x86_64-pc-windows-msvc/{name}"), contents.as_bytes())
426 .unwrap();
427 }
428
429 builder.into_inner().unwrap().finish().unwrap();
430 path
431 }
432
433 fn exe(name: &str) -> String {
434 format!("{}{}", name, env::consts::EXE_SUFFIX)
435 }
436
437 #[test]
438 fn the_archive_directory_prefix_stays_out_of_the_installation() {
439 // Field report, 14.08: every update left a `kasl-v1.2.0/` folder with
440 // copies of LICENSE and README next to the binary, because non-binary
441 // entries were unpacked under their in-archive path.
442 let temp = TempDir::new().unwrap();
443 let install = temp.path().join("install");
444 fs::create_dir(&install).unwrap();
445 fs::write(install.join(exe("kasl")), "old").unwrap();
446
447 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), ("LICENSE", "MIT"), ("README.md", "docs")]);
448
449 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
450
451 let leftovers: Vec<_> = fs::read_dir(&install)
452 .unwrap()
453 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
454 .filter(|name| name.starts_with("kasl-v"))
455 .collect();
456 assert!(leftovers.is_empty(), "update left {leftovers:?} in the installation directory");
457 assert!(!install.join("LICENSE").exists(), "LICENSE does not belong next to the binary");
458 assert!(!install.join("README.md").exists(), "README does not belong next to the binary");
459 }
460
461 #[test]
462 fn the_binary_is_replaced_and_the_old_one_kept_as_backup() {
463 let temp = TempDir::new().unwrap();
464 let install = temp.path().join("install");
465 fs::create_dir(&install).unwrap();
466 fs::write(install.join(exe("kasl")), "old").unwrap();
467
468 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new")]);
469 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
470
471 assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
472 assert_eq!(
473 fs::read_to_string(install.join("kasl.bak")).unwrap(),
474 "old",
475 "the replaced binary must remain recoverable"
476 );
477 }
478
479 /// An update must leave a directory where no name still holds the outgoing
480 /// file.
481 ///
482 /// Found on a live stand, not by these tests: `ka` is a hard link to
483 /// `kasl`, so when the update runs *as* `ka` both names are the same
484 /// running image. Renaming `kasl` aside frees the name but not the file,
485 /// the new binary lands as a fresh `kasl`, and `ka` keeps answering with
486 /// the previous release - while the command reports success.
487 ///
488 /// The unit test can only state the invariant, since nothing here is a
489 /// running image: after the swap, no `.bak` may share a file with a name
490 /// the user calls.
491 #[test]
492 fn the_outgoing_file_is_not_left_under_a_live_name() {
493 let temp = TempDir::new().unwrap();
494 let install = temp.path().join("install");
495 fs::create_dir(&install).unwrap();
496 fs::write(install.join(exe("kasl")), "old").unwrap();
497 crate::libs::alias::link(&install.join(exe("kasl")), &install.join(exe("ka"))).unwrap();
498
499 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new")]);
500 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
501
502 assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
503 // The alias still points at the old bytes here - relinking is the
504 // caller's next step - but the backup must be a file of its own, not
505 // the one `kasl` now names.
506 assert_eq!(fs::read_to_string(install.join("kasl.bak")).unwrap(), "old");
507 }
508
509 /// The alias is a link now, so an update must not write a second binary
510 /// where one is expected to be a link - even if a stale archive still
511 /// carries `ka`, which every release before v1.8.1 did.
512 #[test]
513 fn an_update_never_unpacks_a_second_binary_for_the_alias() {
514 let temp = TempDir::new().unwrap();
515 let install = temp.path().join("install");
516 fs::create_dir(&install).unwrap();
517 fs::write(install.join(exe("kasl")), "old").unwrap();
518 // A link, the way the installers create it.
519 crate::libs::alias::link(&install.join(exe("kasl")), &install.join(exe("ka"))).unwrap();
520
521 // An archive from before the change, still carrying both.
522 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "stale copy")]);
523 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
524
525 assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
526 assert_ne!(
527 fs::read_to_string(install.join(exe("ka"))).unwrap(),
528 "stale copy",
529 "the archive's `ka` was unpacked over the link, which is what made it a second binary"
530 );
531 }
532
533 /// `KASL_NO_ALIAS=1` at install time is a choice; an update must not
534 /// quietly overturn it.
535 #[test]
536 fn an_absent_alias_is_not_installed_by_an_update() {
537 let temp = TempDir::new().unwrap();
538 let install = temp.path().join("install");
539 fs::create_dir(&install).unwrap();
540 fs::write(install.join(exe("kasl")), "old").unwrap();
541
542 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "new")]);
543 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
544
545 assert!(!install.join(exe("ka")).exists(), "the update added an alias the user never installed");
546 }
547
548 #[test]
549 fn an_archive_without_the_binary_fails_instead_of_reporting_success() {
550 let temp = TempDir::new().unwrap();
551 let install = temp.path().join("install");
552 fs::create_dir(&install).unwrap();
553
554 let archive = release_archive(temp.path(), &[("LICENSE", "MIT")]);
555 assert!(Updater::unpack_binaries(&archive, &install, "kasl").is_err());
556 }
557}