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