Skip to main content

isb_core/
self_update.rs

1//! `isb update`: replace the running isb with a release from GitHub.
2//!
3//! The release's tarball for this build's target is checked against the
4//! release's SHA256SUMS, unpacked next to the running binary (so the final
5//! rename stays on one filesystem and is atomic), smoke-tested with
6//! `--version`, and renamed over it. A binary a package manager owns is left
7//! to that manager: replacing it underneath mise, cargo, npm or pip leaves
8//! their records lying about what is installed.
9
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::time::Duration;
13
14use serde_json::Value;
15
16use crate::error::{Error, Result};
17use crate::machine::set_mode;
18
19const RELEASES: &str = "https://github.com/execution-associates/isb/releases/download";
20const LATEST_API: &str = "https://api.github.com/repos/execution-associates/isb/releases/latest";
21
22/// The version of this build.
23pub const CURRENT: &str = env!("CARGO_PKG_VERSION");
24
25/// The release target this build matches, as the release assets name it.
26pub fn host_target() -> Option<&'static str> {
27    match (std::env::consts::OS, std::env::consts::ARCH) {
28        ("linux", "x86_64") => Some("x86_64-unknown-linux-musl"),
29        ("linux", "aarch64") => Some("aarch64-unknown-linux-musl"),
30        ("macos", "aarch64") => Some("aarch64-apple-darwin"),
31        ("macos", "x86_64") => Some("x86_64-apple-darwin"),
32        _ => None,
33    }
34}
35
36/// The release asset name (without `.tar.gz`) for a version and target.
37pub fn release_asset(version: &str, target: &str) -> String {
38    format!("isb-v{version}-{target}")
39}
40
41/// The latest published release's version, without the leading `v`.
42pub fn latest_version() -> Result<String> {
43    let body = fetch(LATEST_API, 1 << 20)?;
44    let v: Value = serde_json::from_slice(&body)?;
45    let tag = v["tag_name"]
46        .as_str()
47        .ok_or_else(|| Error::OperationFailed {
48            step: "find the latest isb release".into(),
49            message: format!("{LATEST_API} answered without a tag_name"),
50        })?;
51    Ok(normalize(tag).to_string())
52}
53
54/// `v1.2.3` and `1.2.3` both mean `1.2.3`.
55pub fn normalize(v: &str) -> &str {
56    v.trim().trim_start_matches('v')
57}
58
59/// Whether `a` is a newer version than `b`. Versions are compared by their
60/// numeric `MAJOR.MINOR.PATCH`; anything that does not parse is never newer.
61pub fn is_newer(a: &str, b: &str) -> bool {
62    match (parse(a), parse(b)) {
63        (Some(a), Some(b)) => a > b,
64        _ => false,
65    }
66}
67
68fn parse(v: &str) -> Option<(u64, u64, u64)> {
69    let core = normalize(v).split(['-', '+']).next()?;
70    let mut it = core.split('.').map(|p| p.parse::<u64>().ok());
71    let t = (it.next()??, it.next()??, it.next()??);
72    it.next().is_none().then_some(t)
73}
74
75/// A package manager that owns an installed isb.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Manager {
78    Mise,
79    Cargo,
80    Npm,
81    Pip,
82}
83
84impl Manager {
85    /// Which manager installed the binary at `exe`, judged by its path.
86    pub fn detect(exe: &Path) -> Option<Manager> {
87        let p = exe.to_string_lossy();
88        if p.contains("/mise/installs/") {
89            Some(Manager::Mise)
90        } else if p.contains("/.cargo/bin/") {
91            Some(Manager::Cargo)
92        } else if p.contains("/node_modules/") {
93            Some(Manager::Npm)
94        } else if p.contains("/site-packages/") || p.contains("/dist-packages/") {
95            Some(Manager::Pip)
96        } else {
97            None
98        }
99    }
100
101    pub fn name(self) -> &'static str {
102        match self {
103            Manager::Mise => "mise",
104            Manager::Cargo => "cargo",
105            Manager::Npm => "npm",
106            Manager::Pip => "pip",
107        }
108    }
109
110    /// The command that upgrades isb through this manager.
111    pub fn upgrade_command(self) -> &'static str {
112        match self {
113            Manager::Mise => "mise use -g github:execution-associates/isb@latest",
114            Manager::Cargo => "cargo install isb --locked",
115            Manager::Npm => "npm install @execution-associates/isb@latest",
116            Manager::Pip => "pip install -U isb-sdk",
117        }
118    }
119}
120
121/// The running binary's real path (symlinks resolved, so the file replaced is
122/// the one that runs, not the link to it).
123pub fn current_exe() -> Result<PathBuf> {
124    Ok(std::env::current_exe()?.canonicalize()?)
125}
126
127/// Download `version` for `target` and atomically replace `exe` with it.
128pub fn install(version: &str, target: &str, exe: &Path) -> Result<()> {
129    let dir = exe
130        .parent()
131        .ok_or_else(|| Error::invalid(format!("{}: no parent directory", exe.display())))?;
132    let scratch = dir.join(format!(".isb-update-{}", std::process::id()));
133    std::fs::create_dir(&scratch).map_err(|e| {
134        if e.kind() == std::io::ErrorKind::PermissionDenied {
135            Error::invalid(format!(
136                "cannot write to {}: rerun with the permissions that installed isb there (sudo)",
137                dir.display()
138            ))
139        } else {
140            e.into()
141        }
142    })?;
143    let r = stage_and_swap(version, target, &scratch, exe);
144    let _ = std::fs::remove_dir_all(&scratch);
145    r
146}
147
148fn stage_and_swap(version: &str, target: &str, scratch: &Path, exe: &Path) -> Result<()> {
149    let staged = scratch.join("isb");
150    download_asset(
151        version,
152        &release_asset(version, target),
153        "no build for this platform in that release",
154        scratch,
155        &staged,
156    )?;
157    set_mode(&staged, 0o755)?;
158    check_runs(&staged, version)?;
159    std::fs::rename(&staged, exe)?;
160    Ok(())
161}
162
163/// The new binary must run here and say it is the version we asked for,
164/// before it replaces a working one.
165fn check_runs(bin: &Path, version: &str) -> Result<()> {
166    let out = Command::new(bin)
167        .arg("--version")
168        .stdin(Stdio::null())
169        .output()
170        .map_err(|e| Error::OperationFailed {
171            step: format!("run the downloaded isb {version}"),
172            message: e.to_string(),
173        })?;
174    let said = String::from_utf8_lossy(&out.stdout).trim().to_string();
175    if !out.status.success() || said != format!("isb {version}") {
176        return Err(Error::OperationFailed {
177            step: format!("run the downloaded isb {version}"),
178            message: format!("`isb --version` said {said:?} ({})", out.status),
179        });
180    }
181    Ok(())
182}
183
184pub(crate) fn fetch(url: &str, limit: u64) -> Result<Vec<u8>> {
185    let agent: ureq::Agent = ureq::Agent::config_builder()
186        .timeout_global(Some(Duration::from_secs(300)))
187        .user_agent(concat!("isb/", env!("CARGO_PKG_VERSION")))
188        .build()
189        .into();
190    let step = || format!("download {url}");
191    let mut resp = agent.get(url).call().map_err(|e| Error::OperationFailed {
192        step: step(),
193        message: e.to_string(),
194    })?;
195    resp.body_mut()
196        .with_config()
197        .limit(limit)
198        .read_to_vec()
199        .map_err(|e| Error::OperationFailed {
200            step: step(),
201            message: e.to_string(),
202        })
203}
204
205/// Download release `asset` (a name without `.tar.gz`), check it against the
206/// release's SHA256SUMS, and unpack its `isb` to `dst`. `hint` follows the
207/// error when the release has no such asset.
208pub(crate) fn download_asset(
209    version: &str,
210    asset: &str,
211    hint: &str,
212    dir: &Path,
213    dst: &Path,
214) -> Result<()> {
215    let base = format!("{RELEASES}/v{version}");
216    let sums =
217        String::from_utf8_lossy(&fetch(&format!("{base}/SHA256SUMS"), 1 << 20)?).into_owned();
218    let want = sums
219        .lines()
220        .find_map(|l| {
221            let (h, f) = l.split_once(char::is_whitespace)?;
222            (f.trim().trim_start_matches('*') == format!("{asset}.tar.gz")).then(|| h.to_string())
223        })
224        .ok_or_else(|| {
225            Error::invalid(format!("release v{version} has no {asset}.tar.gz; {hint}"))
226        })?;
227    let tarball = fetch(&format!("{base}/{asset}.tar.gz"), 256 << 20)?;
228    let got = hex(ring::digest::digest(&ring::digest::SHA256, &tarball).as_ref());
229    if !got.eq_ignore_ascii_case(&want) {
230        return Err(Error::invalid(format!(
231            "{asset}.tar.gz: sha256 {got} does not match SHA256SUMS ({want})"
232        )));
233    }
234    let tgz = dir.join(format!("{asset}.tar.gz"));
235    std::fs::write(&tgz, &tarball)?;
236    let out = Command::new("tar")
237        .arg("-xzf")
238        .arg(&tgz)
239        .arg("-C")
240        .arg(dir)
241        .arg(format!("{asset}/isb"))
242        .stdin(Stdio::null())
243        .output()?;
244    let _ = std::fs::remove_file(&tgz);
245    if !out.status.success() {
246        return Err(Error::OperationFailed {
247            step: format!("unpack {asset}.tar.gz"),
248            message: String::from_utf8_lossy(&out.stderr).trim().to_string(),
249        });
250    }
251    std::fs::rename(dir.join(asset).join("isb"), dst)?;
252    let _ = std::fs::remove_dir_all(dir.join(asset));
253    set_mode(dst, 0o755)
254}
255
256#[doc(hidden)]
257pub fn hex(b: &[u8]) -> String {
258    b.iter().map(|x| format!("{x:02x}")).collect()
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn versions() {
267        assert!(is_newer("1.0.2", "1.0.1"));
268        assert!(is_newer("v1.10.0", "1.9.9"));
269        assert!(is_newer("2.0.0", "1.99.99"));
270        assert!(!is_newer("1.0.1", "1.0.1"));
271        assert!(!is_newer("1.0.0", "1.0.1"));
272        assert!(!is_newer("garbage", "1.0.0"));
273        assert!(!is_newer("1.0", "0.9.0"));
274        assert!(is_newer("1.1.0-rc.1", "1.0.0"));
275    }
276
277    #[test]
278    fn managers() {
279        let d = |p: &str| Manager::detect(Path::new(p));
280        assert_eq!(
281            d("/home/u/.local/share/mise/installs/github-execution-associates-isb/1.0.1/isb"),
282            Some(Manager::Mise)
283        );
284        assert_eq!(d("/home/u/.cargo/bin/isb"), Some(Manager::Cargo));
285        assert_eq!(
286            d("/usr/lib/node_modules/@execution-associates/isb-linux-x64/bin/isb"),
287            Some(Manager::Npm)
288        );
289        assert_eq!(
290            d("/home/u/.venv/lib/python3.12/site-packages/isb/_bin/isb"),
291            Some(Manager::Pip)
292        );
293        assert_eq!(d("/usr/local/bin/isb"), None);
294        assert_eq!(d("/home/u/.local/bin/isb"), None);
295    }
296
297    #[test]
298    fn asset_names() {
299        assert_eq!(
300            release_asset("1.0.1", "aarch64-apple-darwin"),
301            "isb-v1.0.1-aarch64-apple-darwin"
302        );
303        assert!(host_target().is_some_and(|t| t.contains(std::env::consts::ARCH)));
304    }
305}