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}