Skip to main content

f00_cli/
update.rs

1//! Self-update against GitHub Releases (same assets as `install.sh`).
2
3use 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
16/// Current package version from Cargo.
17pub fn current_version() -> &'static str {
18    env!("CARGO_PKG_VERSION")
19}
20
21/// Target triple for the running binary.
22pub fn host_target() -> Result<&'static str> {
23    // Keep in sync with install.sh `rust_target`.
24    #[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
70/// Agent that does **not** follow redirects (so we can read `Location`).
71fn 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    // .../releases/tag/v0.5.0 or .../tag/v0.5.0
87    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
100/// Resolve latest release via GitHub **HTML** redirect (no API rate limit).
101///
102/// Same approach as `install.sh`: `GET /releases/latest` → `Location: .../tag/vX.Y.Z`.
103///
104/// ureq treats 3xx as `Error::Status` when redirects are disabled, so we handle
105/// both `Ok` and `Err(Status)`.
106fn 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        // Some clients/proxies may already resolve; try final URL from body links as last resort.
120        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    // Location may be relative.
127    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
138/// Resolve latest release via GitHub Releases **API** (rate-limited without auth).
139fn 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    // Optional token avoids the 60 req/hr unauthenticated limit.
146    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
173/// Fetch latest stable release tag.
174///
175/// Prefers the unauthenticated HTML redirect (same as `install.sh`), then the
176/// REST API. This avoids hard failure when `api.github.com` returns **403** due
177/// to unauthenticated rate limits.
178pub 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
189/// Compare dotted numeric versions (`1.2.3`). Returns `Ordering`.
190pub 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/// Result of a check-update invocation.
207#[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(&current, &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(&current).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    // Atomic-ish: move current aside, move new into place.
370    #[cfg(unix)]
371    {
372        fs::rename(&current, &backup).context("backup current binary")?;
373        if let Err(e) = fs::rename(&staged, &current) {
374            // try restore
375            let _ = fs::rename(&backup, &current);
376            return Err(e).context("install new binary");
377        }
378        let _ = fs::remove_file(&backup);
379    }
380
381    #[cfg(windows)]
382    {
383        // On Windows, running image may lock the file; write next to it and instruct.
384        let final_path = current.clone();
385        if let Err(e) = fs::rename(&staged, &final_path) {
386            // try replace via remove
387            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
396/// Download latest release and replace the running binary.
397pub fn perform_update() -> Result<(String, String)> {
398    let current = current_version().to_string();
399    let rel = latest_release()?;
400    match cmp_version(&current, &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
441/// Print check-update result; exit code 0 if up to date, 1 if behind, 2 on error.
442pub fn print_check_update() -> i32 {
443    match check_update() {
444        Ok(CheckResult::UpToDate { current, latest }) => {
445            if cmp_version(&current, &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}