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