Skip to main content

kasl/libs/
alias.rs

1//! The short `ka` alias, as a link rather than a second binary.
2//!
3//! Until v1.8.1 `ka` was its own `[[bin]]`: a second, byte-identical 15 MB
4//! executable built, packed into every archive and downloaded by every user, to
5//! give one program a second name. A link costs nothing and cannot go stale,
6//! because there is only ever one set of bytes:
7//!
8//! - **Unix:** a symlink, which `install.sh` already created.
9//! - **Windows:** a *hard* link. Symlinks there need elevation or developer
10//!   mode, which an installer has no business demanding; hard links do not, as
11//!   long as both names are on the same volume - and they are, since the alias
12//!   lands beside the binary.
13//!
14//! The one seam is [`self_update`](crate::libs::update): replacing the binary
15//! renames the old file aside and moves a new one in, which breaks the link, so
16//! an update re-links afterwards - that is what [`refresh`] is for.
17//!
18//! Which name needs repairing depends on which one was typed. An update
19//! replaces the file it is *running from*, so `ka self-update` replaces `ka`
20//! and leaves `kasl` behind, mirroring the usual case exactly. Both are handled
21//! by asking for the [`counterpart`] rather than for "the alias" - asking the
22//! wrong question there cost turnout a vanishing binary (its ADR 0015), and the
23//! same mistake is available here.
24
25use std::path::{Path, PathBuf};
26
27use anyhow::{Context, Result};
28
29/// The alias name, without any platform extension.
30pub const ALIAS: &str = "ka";
31
32/// The primary name, without any platform extension.
33pub const PRIMARY: &str = "kasl";
34
35/// The *other* name of this install: the alias when running as `kasl`, and
36/// `kasl` when running as the alias.
37///
38/// An update replaces the file it is running from, whatever it is called, so
39/// "which name did the swap *not* touch" is the question that is right either
40/// way. A binary the user renamed has no counterpart, and nothing is invented
41/// beside it.
42pub fn counterpart(exe: &Path) -> Option<PathBuf> {
43    let stem = exe.file_stem()?.to_str()?;
44    let other = match stem {
45        ALIAS => PRIMARY,
46        PRIMARY => ALIAS,
47        _ => return None,
48    };
49    Some(sibling_named(exe, other))
50}
51
52/// A path beside `exe` carrying `name` and the same extension.
53fn sibling_named(exe: &Path, name: &str) -> PathBuf {
54    let mut sibling = exe.with_file_name(name);
55    if let Some(extension) = exe.extension() {
56        sibling.set_extension(extension);
57    }
58    sibling
59}
60
61/// Point `alias` at `exe`, replacing whatever is already there.
62///
63/// Both paths must be on the same volume on Windows, which they are whenever
64/// the alias is created beside the binary.
65pub fn link(exe: &Path, alias: &Path) -> Result<()> {
66    // Linking a name to itself would destroy it: the removal below takes the
67    // only copy and there is then nothing left to link from. No caller should
68    // ask - `counterpart` exists so none does - but the consequence is a
69    // binary that disappears, which is too expensive to leave to callers.
70    if alias == exe {
71        anyhow::bail!("refusing to link {} to itself", alias.display());
72    }
73
74    // A link cannot be created over an existing name; the file being replaced
75    // is not the running image, so removing it is allowed.
76    let _ = std::fs::remove_file(alias);
77
78    #[cfg(windows)]
79    let result = std::fs::hard_link(exe, alias);
80    // Relative, matching what `install.sh` writes: a symlink holding just the
81    // file name survives the install directory being moved or renamed, where
82    // one holding an absolute path would dangle.
83    #[cfg(not(windows))]
84    let result = {
85        let target = exe.file_name().unwrap_or(exe.as_os_str());
86        std::os::unix::fs::symlink(target, alias)
87    };
88
89    result.with_context(|| format!("cannot link {} to {}", alias.display(), exe.display()))
90}
91
92/// Re-point this install's *other* name at the binary an update just replaced.
93///
94/// Best-effort by design: the second name is a convenience, and an install that
95/// never had one must not grow one behind the user's back - `KASL_NO_ALIAS` is
96/// their choice to keep. So this only acts when the counterpart is already
97/// there, and reports what it did for the caller to print.
98pub fn refresh(exe: &Path) -> Outcome {
99    let Some(other) = counterpart(exe) else {
100        return Outcome::Absent;
101    };
102    if !other.exists() {
103        return Outcome::Absent;
104    }
105    match link(exe, &other) {
106        Ok(()) => Outcome::Relinked(other),
107        Err(err) => Outcome::Failed(other, err.to_string()),
108    }
109}
110
111/// What [`refresh`] found and did.
112#[derive(Debug, PartialEq)]
113pub enum Outcome {
114    /// This install has only the one name - nothing to refresh.
115    Absent,
116    /// The second name was re-pointed at the new binary.
117    Relinked(PathBuf),
118    /// The second name is installed but could not be re-pointed; it is stale
119    /// and the user has to be told, since it still answers under its own name.
120    Failed(PathBuf, String),
121}
122
123impl Outcome {
124    /// The line to print after an update, if any.
125    ///
126    /// Phrased around the path rather than the word "alias": an update run as
127    /// `ka` repairs `kasl`, and calling that the alias would name the wrong
128    /// file for whoever is reading.
129    pub fn message(&self) -> Option<String> {
130        match self {
131            Self::Absent => None,
132            Self::Relinked(other) => Some(format!("{} updated too.", other.display())),
133            Self::Failed(other, err) => Some(format!(
134                "Warning: {} still points at the previous version and could not be relinked ({err}).\n\
135                 Re-run the installer to fix it.",
136                other.display()
137            )),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    /// The question an update has to ask is "which name did I *not* replace",
147    /// and the answer depends on how it was launched.
148    #[test]
149    fn the_counterpart_is_whichever_name_is_not_running() {
150        let dir = tempfile::tempdir().unwrap();
151        let primary = dir.path().join(if cfg!(windows) { "kasl.exe" } else { "kasl" });
152        let alias = sibling_named(&primary, ALIAS);
153
154        assert_eq!(counterpart(&primary).unwrap(), alias);
155        assert_eq!(counterpart(&alias).unwrap(), primary);
156    }
157
158    /// A binary the user renamed is not one of ours to pair up.
159    #[test]
160    fn a_renamed_binary_has_no_counterpart() {
161        let dir = tempfile::tempdir().unwrap();
162        assert_eq!(counterpart(&dir.path().join("my-kasl")), None);
163    }
164
165    /// The whole point of the link: one set of bytes answers to both names, so
166    /// replacing the content through one name shows through the other.
167    #[test]
168    fn a_linked_alias_shares_the_binary_it_points_at() {
169        let dir = tempfile::tempdir().unwrap();
170        let exe = dir.path().join("kasl");
171        std::fs::write(&exe, b"version one").unwrap();
172        let alias = sibling_named(&exe, ALIAS);
173
174        link(&exe, &alias).unwrap();
175
176        // Identity, observed rather than asked about: writing through one name
177        // shows through the other only if there is one file behind both.
178        std::fs::write(&exe, b"version two").unwrap();
179        assert_eq!(
180            std::fs::read(&alias).unwrap(),
181            b"version two",
182            "the alias must be the same file as the binary, not a copy of it"
183        );
184    }
185
186    /// turnout's field report, guarded here before it can happen: an update
187    /// launched as the alias asked to link that name to itself, and `link`
188    /// removes the destination before creating it - so the file was deleted
189    /// with nothing left to link from.
190    #[test]
191    fn linking_a_name_to_itself_is_refused_rather_than_destroying_it() {
192        let dir = tempfile::tempdir().unwrap();
193        let exe = dir.path().join("ka");
194        std::fs::write(&exe, b"the only copy").unwrap();
195
196        assert!(link(&exe, &exe).is_err());
197        assert!(exe.exists(), "the binary must survive a self-link attempt");
198        assert_eq!(std::fs::read(&exe).unwrap(), b"the only copy");
199    }
200
201    /// An update run under the alias repairs the primary name, which is the
202    /// one the swap left on the outgoing release.
203    #[test]
204    fn refreshing_from_the_alias_repairs_the_primary_name() {
205        let dir = tempfile::tempdir().unwrap();
206        let primary = dir.path().join("kasl");
207        let alias = dir.path().join("ka");
208        // After the swap: the alias is the new binary, the primary is stale.
209        std::fs::write(&alias, b"new version").unwrap();
210        std::fs::write(&primary, b"old version").unwrap();
211
212        assert!(matches!(refresh(&alias), Outcome::Relinked(_)));
213
214        assert!(alias.exists(), "the running name must not be removed");
215        assert_eq!(std::fs::read(&primary).unwrap(), b"new version");
216    }
217
218    /// The usual direction: an update run as `kasl` repairs `ka`.
219    #[test]
220    fn refreshing_from_the_primary_repairs_the_alias() {
221        let dir = tempfile::tempdir().unwrap();
222        let primary = dir.path().join("kasl");
223        let alias = dir.path().join("ka");
224        std::fs::write(&primary, b"new version").unwrap();
225        std::fs::write(&alias, b"old version").unwrap();
226
227        assert!(matches!(refresh(&primary), Outcome::Relinked(_)));
228
229        assert_eq!(std::fs::read(&alias).unwrap(), b"new version");
230    }
231
232    /// An install without an alias must not grow one: `KASL_NO_ALIAS` is a
233    /// choice the user made, and an update is no place to overrule it.
234    #[test]
235    fn refresh_leaves_an_aliasless_install_alone() {
236        let dir = tempfile::tempdir().unwrap();
237        let exe = dir.path().join("kasl");
238        std::fs::write(&exe, b"binary").unwrap();
239
240        assert_eq!(refresh(&exe), Outcome::Absent);
241        assert!(!sibling_named(&exe, ALIAS).exists());
242    }
243
244    /// A stale alias must never fail quietly: it keeps answering to its own
245    /// name, so the user has to hear about it.
246    #[test]
247    fn a_failed_relink_names_the_file_and_a_way_out() {
248        let outcome = Outcome::Failed(PathBuf::from("/home/dev/.local/bin/ka"), "permission denied".to_string());
249        let message = outcome.message().expect("a failure has to be reported");
250        assert!(message.contains("/home/dev/.local/bin/ka"));
251        assert!(message.contains("previous version"));
252        assert!(message.contains("installer"));
253    }
254}