1use 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";
23pub const RELEASE_KEYS: &[&str] =
28 &["4f08d05a2ffaf58f40d4d0e658a9934e5246de1b472ccd2143af6c928adfd51a"];
29const FIRST_SIGNED: &str = "1.1.1";
31
32pub 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
38pub const CURRENT: &str = env!("CARGO_PKG_VERSION");
40
41pub 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
52pub fn release_asset(version: &str, target: &str) -> String {
54 format!("isb-v{version}-{target}")
55}
56
57pub 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
70pub fn normalize(v: &str) -> &str {
72 v.trim().trim_start_matches('v')
73}
74
75pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum Manager {
94 Mise,
95 Cargo,
96 Npm,
97 Pip,
98}
99
100impl Manager {
101 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 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 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
151pub fn current_exe() -> Result<PathBuf> {
154 Ok(std::env::current_exe()?.canonicalize()?)
155}
156
157pub 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
193fn 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
235pub(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
293pub 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 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}