1use 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
22pub const CURRENT: &str = env!("CARGO_PKG_VERSION");
24
25pub 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
36pub fn release_asset(version: &str, target: &str) -> String {
38 format!("isb-v{version}-{target}")
39}
40
41pub 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
54pub fn normalize(v: &str) -> &str {
56 v.trim().trim_start_matches('v')
57}
58
59pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Manager {
78 Mise,
79 Cargo,
80 Npm,
81 Pip,
82}
83
84impl Manager {
85 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 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
121pub fn current_exe() -> Result<PathBuf> {
124 Ok(std::env::current_exe()?.canonicalize()?)
125}
126
127pub 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
163fn 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
205pub(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}