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