1use 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";
21pub const RELEASE_KEYS: &[&str] =
26 &["4f08d05a2ffaf58f40d4d0e658a9934e5246de1b472ccd2143af6c928adfd51a"];
27const FIRST_SIGNED: &str = "1.1.1";
29
30const LATEST_API: &str = "https://api.github.com/repos/execution-associates/isb/releases/latest";
31
32pub const CURRENT: &str = env!("CARGO_PKG_VERSION");
34
35pub 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
46pub fn release_asset(version: &str, target: &str) -> String {
48 format!("isb-v{version}-{target}")
49}
50
51pub 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
64pub fn normalize(v: &str) -> &str {
66 v.trim().trim_start_matches('v')
67}
68
69pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Manager {
88 Mise,
89 Cargo,
90 Npm,
91 Pip,
92}
93
94impl Manager {
95 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 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
131pub fn current_exe() -> Result<PathBuf> {
134 Ok(std::env::current_exe()?.canonicalize()?)
135}
136
137pub 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
173fn 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
215pub(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
273pub 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 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}