1use std::env;
4use std::fs::{self, File};
5use std::io::{self, Read};
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, bail, Context, Result};
9use sha2::{Digest, Sha256};
10
11const BINARY: &str = "f00";
12const RELEASES: &str = "https://github.com/theesfeld/f00/releases";
13const RELEASES_LATEST: &str = "https://github.com/theesfeld/f00/releases/latest";
14const API_LATEST: &str = "https://api.github.com/repos/theesfeld/f00/releases/latest";
15
16pub fn current_version() -> &'static str {
18 env!("CARGO_PKG_VERSION")
19}
20
21pub fn host_target() -> Result<&'static str> {
23 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
25 {
26 return Ok("x86_64-unknown-linux-gnu");
27 }
28 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
29 {
30 return Ok("aarch64-unknown-linux-gnu");
31 }
32 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
33 {
34 return Ok("x86_64-apple-darwin");
35 }
36 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
37 {
38 return Ok("aarch64-apple-darwin");
39 }
40 #[cfg(all(target_os = "freebsd", target_arch = "x86_64"))]
41 {
42 return Ok("x86_64-unknown-freebsd");
43 }
44 #[cfg(all(target_os = "freebsd", target_arch = "aarch64"))]
45 {
46 return Ok("aarch64-unknown-freebsd");
47 }
48 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
49 {
50 return Ok("x86_64-pc-windows-msvc");
51 }
52 #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
53 {
54 return Ok("aarch64-pc-windows-msvc");
55 }
56 #[allow(unreachable_code)]
57 Err(anyhow!(
58 "unsupported platform for self-update; install manually from {}",
59 RELEASES
60 ))
61}
62
63fn agent() -> ureq::Agent {
64 ureq::AgentBuilder::new()
65 .user_agent(concat!("f00/", env!("CARGO_PKG_VERSION")))
66 .timeout(std::time::Duration::from_secs(60))
67 .build()
68}
69
70fn agent_no_redirect() -> ureq::Agent {
72 ureq::AgentBuilder::new()
73 .user_agent(concat!("f00/", env!("CARGO_PKG_VERSION")))
74 .timeout(std::time::Duration::from_secs(60))
75 .redirects(0)
76 .build()
77}
78
79#[derive(Debug, Clone)]
80pub struct ReleaseInfo {
81 pub tag: String,
82 pub version: String,
83}
84
85fn tag_from_release_url(url: &str) -> Option<ReleaseInfo> {
86 let tag = url
88 .trim_end_matches('/')
89 .rsplit('/')
90 .next()
91 .filter(|s| s.starts_with('v') || s.chars().next().is_some_and(|c| c.is_ascii_digit()))?
92 .to_string();
93 if tag.is_empty() || tag == "latest" {
94 return None;
95 }
96 let version = tag.trim_start_matches('v').to_string();
97 Some(ReleaseInfo { tag, version })
98}
99
100fn latest_release_via_redirect() -> Result<ReleaseInfo> {
107 let agent = agent_no_redirect();
108 let resp = match agent.get(RELEASES_LATEST).call() {
109 Ok(r) => r,
110 Err(ureq::Error::Status(code, r)) if (300..400).contains(&code) => r,
111 Err(ureq::Error::Status(code, r)) => {
112 let body = r.into_string().unwrap_or_default();
113 bail!("releases/latest HTTP {code}: {body}");
114 }
115 Err(e) => return Err(e).context("fetch releases/latest redirect"),
116 };
117 let status = resp.status();
118 if !(300..400).contains(&status) {
119 bail!("releases/latest expected redirect, got HTTP {status}");
121 }
122 let loc = resp
123 .header("location")
124 .ok_or_else(|| anyhow!("releases/latest missing Location header"))?
125 .to_string();
126 let loc = if loc.starts_with("http") {
128 loc
129 } else if loc.starts_with('/') {
130 format!("https://github.com{loc}")
131 } else {
132 format!("{RELEASES}/{loc}")
133 };
134 tag_from_release_url(&loc)
135 .ok_or_else(|| anyhow!("could not parse release tag from Location: {loc}"))
136}
137
138fn latest_release_via_api() -> Result<ReleaseInfo> {
140 let agent = agent();
141 let mut req = agent
142 .get(API_LATEST)
143 .set("Accept", "application/vnd.github+json")
144 .set("X-GitHub-Api-Version", "2022-11-28");
145 if let Ok(token) = env::var("GITHUB_TOKEN").or_else(|_| env::var("GH_TOKEN")) {
147 if !token.is_empty() {
148 req = req.set("Authorization", &format!("Bearer {token}"));
149 }
150 }
151 let resp = match req.call() {
152 Ok(r) => r,
153 Err(ureq::Error::Status(code, resp)) => {
154 let body = resp.into_string().unwrap_or_default();
155 bail!(
156 "GitHub API HTTP {code} (rate limit or auth). \
157 Set GITHUB_TOKEN/GH_TOKEN, wait for rate limit reset, \
158 or reinstall: curl -fsSL https://f00.sh/install.sh | bash\n{body}"
159 );
160 }
161 Err(e) => return Err(e).context("fetch latest release from GitHub API"),
162 };
163 let body: serde_json::Value = resp.into_json().context("parse releases JSON")?;
164 let tag = body
165 .get("tag_name")
166 .and_then(|v| v.as_str())
167 .ok_or_else(|| anyhow!("releases API missing tag_name"))?
168 .to_string();
169 let version = tag.trim_start_matches('v').to_string();
170 Ok(ReleaseInfo { tag, version })
171}
172
173pub fn latest_release() -> Result<ReleaseInfo> {
179 match latest_release_via_redirect() {
180 Ok(info) => Ok(info),
181 Err(redir_err) => latest_release_via_api().map_err(|api_err| {
182 anyhow!(
183 "could not resolve latest release\n redirect: {redir_err:#}\n api: {api_err:#}"
184 )
185 }),
186 }
187}
188
189pub fn cmp_version(a: &str, b: &str) -> std::cmp::Ordering {
191 let parse = |s: &str| -> Vec<u64> {
192 s.trim_start_matches('v')
193 .split(|c: char| !c.is_ascii_digit())
194 .filter(|p| !p.is_empty())
195 .filter_map(|p| p.parse().ok())
196 .collect()
197 };
198 let mut aa = parse(a);
199 let mut bb = parse(b);
200 let n = aa.len().max(bb.len());
201 aa.resize(n, 0);
202 bb.resize(n, 0);
203 aa.cmp(&bb)
204}
205
206#[derive(Debug)]
208pub enum CheckResult {
209 UpToDate {
210 current: String,
211 latest: String,
212 },
213 UpdateAvailable {
214 current: String,
215 latest: String,
216 tag: String,
217 },
218}
219
220pub fn check_update() -> Result<CheckResult> {
221 let current = current_version().to_string();
222 let rel = latest_release()?;
223 if cmp_version(¤t, &rel.version) == std::cmp::Ordering::Less {
224 Ok(CheckResult::UpdateAvailable {
225 current,
226 latest: rel.version,
227 tag: rel.tag,
228 })
229 } else {
230 Ok(CheckResult::UpToDate {
231 current,
232 latest: rel.version,
233 })
234 }
235}
236
237fn asset_name(target: &str) -> String {
238 if cfg!(windows) {
239 format!("{BINARY}-{target}.zip")
240 } else {
241 format!("{BINARY}-{target}.tar.gz")
242 }
243}
244
245fn download_bytes(url: &str) -> Result<Vec<u8>> {
246 let agent = agent();
247 let resp = agent
248 .get(url)
249 .call()
250 .with_context(|| format!("GET {url}"))?;
251 let mut data = Vec::new();
252 resp.into_reader()
253 .read_to_end(&mut data)
254 .context("read download body")?;
255 Ok(data)
256}
257
258fn verify_sha256(data: &[u8], sums: &str, asset: &str) -> Result<()> {
259 let mut expected = None;
260 for line in sums.lines() {
261 let line = line.trim();
262 if line.ends_with(asset) || line.split_whitespace().nth(1) == Some(asset) {
263 expected = line.split_whitespace().next().map(|s| s.to_string());
264 break;
265 }
266 }
267 let Some(expected) = expected else {
268 eprintln!("f00: warning: no SHA256SUMS entry for {asset}; skipping verify");
269 return Ok(());
270 };
271 let mut hasher = Sha256::new();
272 hasher.update(data);
273 let actual = format!("{:x}", hasher.finalize());
274 if actual != expected {
275 bail!("checksum mismatch for {asset}\n expected: {expected}\n actual: {actual}");
276 }
277 Ok(())
278}
279
280fn extract_binary(archive: &[u8], dest_dir: &Path) -> Result<PathBuf> {
281 let out_bin = dest_dir.join(if cfg!(windows) {
282 format!("{BINARY}.exe")
283 } else {
284 BINARY.to_string()
285 });
286
287 #[cfg(not(windows))]
288 {
289 use flate2::read::GzDecoder;
290 use tar::Archive;
291 let dec = GzDecoder::new(archive);
292 let mut ar = Archive::new(dec);
293 let mut found = false;
294 for ent in ar.entries().context("read tar")? {
295 let mut ent = ent.context("tar entry")?;
296 let path = ent.path().context("tar path")?.into_owned();
297 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
298 if name == BINARY {
299 let mut f = File::create(&out_bin).context("create temp binary")?;
300 io::copy(&mut ent, &mut f).context("extract binary")?;
301 #[cfg(unix)]
302 {
303 use std::os::unix::fs::PermissionsExt;
304 let mut perms = fs::metadata(&out_bin)?.permissions();
305 perms.set_mode(0o755);
306 fs::set_permissions(&out_bin, perms)?;
307 }
308 found = true;
309 break;
310 }
311 }
312 if !found {
313 bail!("binary {BINARY} not found in archive");
314 }
315 }
316
317 #[cfg(windows)]
318 {
319 use std::io::Cursor;
320 let reader = Cursor::new(archive);
321 let mut zip = zip::ZipArchive::new(reader).context("open zip")?;
322 let mut found = false;
323 for i in 0..zip.len() {
324 let mut file = zip.by_index(i).context("zip entry")?;
325 let name = file.name().to_string();
326 if name.ends_with("f00.exe") || name.ends_with("/f00.exe") || name == "f00.exe" {
327 let mut f = File::create(&out_bin).context("create temp binary")?;
328 io::copy(&mut file, &mut f).context("extract binary")?;
329 found = true;
330 break;
331 }
332 }
333 if !found {
334 bail!("binary f00.exe not found in zip");
335 }
336 }
337
338 Ok(out_bin)
339}
340
341fn replace_current_exe(new_bin: &Path) -> Result<()> {
342 let current = env::current_exe().context("current_exe")?;
343 let current = fs::canonicalize(¤t).unwrap_or(current);
344 let dir = current
345 .parent()
346 .ok_or_else(|| anyhow!("cannot determine install directory"))?;
347
348 let staged = dir.join(format!(
349 ".f00-update-{}-{}",
350 std::process::id(),
351 std::time::SystemTime::now()
352 .duration_since(std::time::UNIX_EPOCH)
353 .map(|d| d.as_nanos())
354 .unwrap_or(0)
355 ));
356
357 fs::copy(new_bin, &staged).context("stage new binary")?;
358 #[cfg(unix)]
359 {
360 use std::os::unix::fs::PermissionsExt;
361 let mut perms = fs::metadata(&staged)?.permissions();
362 perms.set_mode(0o755);
363 fs::set_permissions(&staged, perms)?;
364 }
365
366 let backup = dir.join(format!("{BINARY}.old"));
367 let _ = fs::remove_file(&backup);
368
369 #[cfg(unix)]
371 {
372 fs::rename(¤t, &backup).context("backup current binary")?;
373 if let Err(e) = fs::rename(&staged, ¤t) {
374 let _ = fs::rename(&backup, ¤t);
376 return Err(e).context("install new binary");
377 }
378 let _ = fs::remove_file(&backup);
379 }
380
381 #[cfg(windows)]
382 {
383 let final_path = current.clone();
385 if let Err(e) = fs::rename(&staged, &final_path) {
386 let _ = fs::remove_file(&final_path);
388 fs::rename(&staged, &final_path)
389 .with_context(|| format!("replace binary failed: {e}"))?;
390 }
391 }
392
393 Ok(())
394}
395
396pub fn perform_update() -> Result<(String, String)> {
398 let current = current_version().to_string();
399 let rel = latest_release()?;
400 match cmp_version(¤t, &rel.version) {
401 std::cmp::Ordering::Less => {}
402 std::cmp::Ordering::Equal => {
403 println!("f00 {current} is already up to date");
404 return Ok((current.clone(), rel.version));
405 }
406 std::cmp::Ordering::Greater => {
407 println!(
408 "f00 {current} is newer than published latest {} — nothing to install",
409 rel.version
410 );
411 return Ok((current.clone(), rel.version));
412 }
413 }
414
415 let target = host_target()?;
416 let asset = asset_name(target);
417 let url = format!("{RELEASES}/download/{}/{asset}", rel.tag);
418 eprintln!("f00: downloading {} …", rel.tag);
419
420 let data = download_bytes(&url)?;
421 let sums_url = format!("{RELEASES}/download/{}/SHA256SUMS", rel.tag);
422 if let Ok(sums) = download_bytes(&sums_url) {
423 let sums = String::from_utf8_lossy(&sums);
424 verify_sha256(&data, &sums, &asset)?;
425 eprintln!("f00: checksum verified");
426 } else {
427 eprintln!("f00: warning: SHA256SUMS not available; skipping verify");
428 }
429
430 let tmp = env::temp_dir().join(format!("f00-upd-{}", std::process::id()));
431 let _ = fs::remove_dir_all(&tmp);
432 fs::create_dir_all(&tmp)?;
433 let new_bin = extract_binary(&data, &tmp)?;
434 replace_current_exe(&new_bin)?;
435 let _ = fs::remove_dir_all(&tmp);
436
437 println!("f00: {current} → {}", rel.version);
438 Ok((current, rel.version))
439}
440
441pub fn print_check_update() -> i32 {
443 match check_update() {
444 Ok(CheckResult::UpToDate { current, latest }) => {
445 if cmp_version(¤t, &latest) == std::cmp::Ordering::Greater {
446 println!("f00 {current} (published latest {latest}) — local is newer");
447 } else {
448 println!("f00 {current} (latest {latest}) — up to date");
449 }
450 0
451 }
452 Ok(CheckResult::UpdateAvailable {
453 current,
454 latest,
455 tag,
456 }) => {
457 println!("f00 {current} → {latest} available ({tag})");
458 println!("Run: f00 --update");
459 1
460 }
461 Err(e) => {
462 eprintln!("f00: check-update failed: {e:#}");
463 2
464 }
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn version_cmp_basic() {
474 assert_eq!(cmp_version("0.3.0", "0.4.0"), std::cmp::Ordering::Less);
475 assert_eq!(cmp_version("0.4.0", "0.4.0"), std::cmp::Ordering::Equal);
476 assert_eq!(cmp_version("0.4.1", "0.4.0"), std::cmp::Ordering::Greater);
477 assert_eq!(cmp_version("v0.4.0", "0.3.9"), std::cmp::Ordering::Greater);
478 }
479
480 #[test]
481 fn current_version_nonzero() {
482 assert!(!current_version().is_empty());
483 }
484
485 #[test]
486 fn parse_tag_from_location() {
487 let info =
488 tag_from_release_url("https://github.com/theesfeld/f00/releases/tag/v0.5.0").unwrap();
489 assert_eq!(info.tag, "v0.5.0");
490 assert_eq!(info.version, "0.5.0");
491 }
492}