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 API_LATEST: &str = "https://api.github.com/repos/theesfeld/f00/releases/latest";
14
15/// Current package version from Cargo.
16pub fn current_version() -> &'static str {
17    env!("CARGO_PKG_VERSION")
18}
19
20/// Target triple for the running binary.
21pub fn host_target() -> Result<&'static str> {
22    // Keep in sync with install.sh `rust_target`.
23    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
24    {
25        return Ok("x86_64-unknown-linux-gnu");
26    }
27    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
28    {
29        return Ok("aarch64-unknown-linux-gnu");
30    }
31    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
32    {
33        return Ok("x86_64-apple-darwin");
34    }
35    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
36    {
37        return Ok("aarch64-apple-darwin");
38    }
39    #[cfg(all(target_os = "freebsd", target_arch = "x86_64"))]
40    {
41        return Ok("x86_64-unknown-freebsd");
42    }
43    #[cfg(all(target_os = "freebsd", target_arch = "aarch64"))]
44    {
45        return Ok("aarch64-unknown-freebsd");
46    }
47    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
48    {
49        return Ok("x86_64-pc-windows-msvc");
50    }
51    #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
52    {
53        return Ok("aarch64-pc-windows-msvc");
54    }
55    #[allow(unreachable_code)]
56    Err(anyhow!(
57        "unsupported platform for self-update; install manually from {}",
58        RELEASES
59    ))
60}
61
62fn agent() -> ureq::Agent {
63    ureq::AgentBuilder::new()
64        .user_agent(concat!("f00/", env!("CARGO_PKG_VERSION")))
65        .timeout(std::time::Duration::from_secs(60))
66        .build()
67}
68
69#[derive(Debug, Clone)]
70pub struct ReleaseInfo {
71    pub tag: String,
72    pub version: String,
73}
74
75/// Fetch latest stable release tag from GitHub Releases API.
76pub fn latest_release() -> Result<ReleaseInfo> {
77    let agent = agent();
78    let resp = agent
79        .get(API_LATEST)
80        .set("Accept", "application/vnd.github+json")
81        .call()
82        .context("fetch latest release from GitHub")?;
83    let body: serde_json::Value = resp.into_json().context("parse releases JSON")?;
84    let tag = body
85        .get("tag_name")
86        .and_then(|v| v.as_str())
87        .ok_or_else(|| anyhow!("releases API missing tag_name"))?
88        .to_string();
89    let version = tag.trim_start_matches('v').to_string();
90    Ok(ReleaseInfo { tag, version })
91}
92
93/// Compare dotted numeric versions (`1.2.3`). Returns `Ordering`.
94pub fn cmp_version(a: &str, b: &str) -> std::cmp::Ordering {
95    let parse = |s: &str| -> Vec<u64> {
96        s.trim_start_matches('v')
97            .split(|c: char| !c.is_ascii_digit())
98            .filter(|p| !p.is_empty())
99            .filter_map(|p| p.parse().ok())
100            .collect()
101    };
102    let mut aa = parse(a);
103    let mut bb = parse(b);
104    let n = aa.len().max(bb.len());
105    aa.resize(n, 0);
106    bb.resize(n, 0);
107    aa.cmp(&bb)
108}
109
110/// Result of a check-update invocation.
111#[derive(Debug)]
112pub enum CheckResult {
113    UpToDate {
114        current: String,
115        latest: String,
116    },
117    UpdateAvailable {
118        current: String,
119        latest: String,
120        tag: String,
121    },
122}
123
124pub fn check_update() -> Result<CheckResult> {
125    let current = current_version().to_string();
126    let rel = latest_release()?;
127    if cmp_version(&current, &rel.version) == std::cmp::Ordering::Less {
128        Ok(CheckResult::UpdateAvailable {
129            current,
130            latest: rel.version,
131            tag: rel.tag,
132        })
133    } else {
134        Ok(CheckResult::UpToDate {
135            current,
136            latest: rel.version,
137        })
138    }
139}
140
141fn asset_name(target: &str) -> String {
142    if cfg!(windows) {
143        format!("{BINARY}-{target}.zip")
144    } else {
145        format!("{BINARY}-{target}.tar.gz")
146    }
147}
148
149fn download_bytes(url: &str) -> Result<Vec<u8>> {
150    let agent = agent();
151    let resp = agent
152        .get(url)
153        .call()
154        .with_context(|| format!("GET {url}"))?;
155    let mut data = Vec::new();
156    resp.into_reader()
157        .read_to_end(&mut data)
158        .context("read download body")?;
159    Ok(data)
160}
161
162fn verify_sha256(data: &[u8], sums: &str, asset: &str) -> Result<()> {
163    let mut expected = None;
164    for line in sums.lines() {
165        let line = line.trim();
166        if line.ends_with(asset) || line.split_whitespace().nth(1) == Some(asset) {
167            expected = line.split_whitespace().next().map(|s| s.to_string());
168            break;
169        }
170    }
171    let Some(expected) = expected else {
172        eprintln!("f00: warning: no SHA256SUMS entry for {asset}; skipping verify");
173        return Ok(());
174    };
175    let mut hasher = Sha256::new();
176    hasher.update(data);
177    let actual = format!("{:x}", hasher.finalize());
178    if actual != expected {
179        bail!("checksum mismatch for {asset}\n  expected: {expected}\n  actual:   {actual}");
180    }
181    Ok(())
182}
183
184fn extract_binary(archive: &[u8], dest_dir: &Path) -> Result<PathBuf> {
185    let out_bin = dest_dir.join(if cfg!(windows) {
186        format!("{BINARY}.exe")
187    } else {
188        BINARY.to_string()
189    });
190
191    #[cfg(not(windows))]
192    {
193        use flate2::read::GzDecoder;
194        use tar::Archive;
195        let dec = GzDecoder::new(archive);
196        let mut ar = Archive::new(dec);
197        let mut found = false;
198        for ent in ar.entries().context("read tar")? {
199            let mut ent = ent.context("tar entry")?;
200            let path = ent.path().context("tar path")?.into_owned();
201            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
202            if name == BINARY {
203                let mut f = File::create(&out_bin).context("create temp binary")?;
204                io::copy(&mut ent, &mut f).context("extract binary")?;
205                #[cfg(unix)]
206                {
207                    use std::os::unix::fs::PermissionsExt;
208                    let mut perms = fs::metadata(&out_bin)?.permissions();
209                    perms.set_mode(0o755);
210                    fs::set_permissions(&out_bin, perms)?;
211                }
212                found = true;
213                break;
214            }
215        }
216        if !found {
217            bail!("binary {BINARY} not found in archive");
218        }
219    }
220
221    #[cfg(windows)]
222    {
223        use std::io::Cursor;
224        let reader = Cursor::new(archive);
225        let mut zip = zip::ZipArchive::new(reader).context("open zip")?;
226        let mut found = false;
227        for i in 0..zip.len() {
228            let mut file = zip.by_index(i).context("zip entry")?;
229            let name = file.name().to_string();
230            if name.ends_with("f00.exe") || name.ends_with("/f00.exe") || name == "f00.exe" {
231                let mut f = File::create(&out_bin).context("create temp binary")?;
232                io::copy(&mut file, &mut f).context("extract binary")?;
233                found = true;
234                break;
235            }
236        }
237        if !found {
238            bail!("binary f00.exe not found in zip");
239        }
240    }
241
242    Ok(out_bin)
243}
244
245fn replace_current_exe(new_bin: &Path) -> Result<()> {
246    let current = env::current_exe().context("current_exe")?;
247    let current = fs::canonicalize(&current).unwrap_or(current);
248    let dir = current
249        .parent()
250        .ok_or_else(|| anyhow!("cannot determine install directory"))?;
251
252    let staged = dir.join(format!(
253        ".f00-update-{}-{}",
254        std::process::id(),
255        std::time::SystemTime::now()
256            .duration_since(std::time::UNIX_EPOCH)
257            .map(|d| d.as_nanos())
258            .unwrap_or(0)
259    ));
260
261    fs::copy(new_bin, &staged).context("stage new binary")?;
262    #[cfg(unix)]
263    {
264        use std::os::unix::fs::PermissionsExt;
265        let mut perms = fs::metadata(&staged)?.permissions();
266        perms.set_mode(0o755);
267        fs::set_permissions(&staged, perms)?;
268    }
269
270    let backup = dir.join(format!("{BINARY}.old"));
271    let _ = fs::remove_file(&backup);
272
273    // Atomic-ish: move current aside, move new into place.
274    #[cfg(unix)]
275    {
276        fs::rename(&current, &backup).context("backup current binary")?;
277        if let Err(e) = fs::rename(&staged, &current) {
278            // try restore
279            let _ = fs::rename(&backup, &current);
280            return Err(e).context("install new binary");
281        }
282        let _ = fs::remove_file(&backup);
283    }
284
285    #[cfg(windows)]
286    {
287        // On Windows, running image may lock the file; write next to it and instruct.
288        let final_path = current.clone();
289        if let Err(e) = fs::rename(&staged, &final_path) {
290            // try replace via remove
291            let _ = fs::remove_file(&final_path);
292            fs::rename(&staged, &final_path)
293                .with_context(|| format!("replace binary failed: {e}"))?;
294        }
295    }
296
297    Ok(())
298}
299
300/// Download latest release and replace the running binary.
301pub fn perform_update() -> Result<(String, String)> {
302    let current = current_version().to_string();
303    let rel = latest_release()?;
304    if cmp_version(&current, &rel.version) != std::cmp::Ordering::Less {
305        println!(
306            "f00 {current} is already up to date (latest {})",
307            rel.version
308        );
309        return Ok((current.clone(), rel.version));
310    }
311
312    let target = host_target()?;
313    let asset = asset_name(target);
314    let url = format!("{RELEASES}/download/{}/{asset}", rel.tag);
315    eprintln!("f00: downloading {} …", rel.tag);
316
317    let data = download_bytes(&url)?;
318    let sums_url = format!("{RELEASES}/download/{}/SHA256SUMS", rel.tag);
319    if let Ok(sums) = download_bytes(&sums_url) {
320        let sums = String::from_utf8_lossy(&sums);
321        verify_sha256(&data, &sums, &asset)?;
322        eprintln!("f00: checksum verified");
323    } else {
324        eprintln!("f00: warning: SHA256SUMS not available; skipping verify");
325    }
326
327    let tmp = env::temp_dir().join(format!("f00-upd-{}", std::process::id()));
328    let _ = fs::remove_dir_all(&tmp);
329    fs::create_dir_all(&tmp)?;
330    let new_bin = extract_binary(&data, &tmp)?;
331    replace_current_exe(&new_bin)?;
332    let _ = fs::remove_dir_all(&tmp);
333
334    println!("f00: {current} → {}", rel.version);
335    Ok((current, rel.version))
336}
337
338/// Print check-update result; exit code 0 if up to date, 1 if behind, 2 on error.
339pub fn print_check_update() -> i32 {
340    match check_update() {
341        Ok(CheckResult::UpToDate { current, latest }) => {
342            println!("f00 {current} (latest {latest}) — up to date");
343            0
344        }
345        Ok(CheckResult::UpdateAvailable {
346            current,
347            latest,
348            tag,
349        }) => {
350            println!("f00 {current} → {latest} available ({tag})");
351            println!("Run: f00 --update");
352            1
353        }
354        Err(e) => {
355            eprintln!("f00: check-update failed: {e:#}");
356            2
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn version_cmp_basic() {
367        assert_eq!(cmp_version("0.3.0", "0.4.0"), std::cmp::Ordering::Less);
368        assert_eq!(cmp_version("0.4.0", "0.4.0"), std::cmp::Ordering::Equal);
369        assert_eq!(cmp_version("0.4.1", "0.4.0"), std::cmp::Ordering::Greater);
370        assert_eq!(cmp_version("v0.4.0", "0.3.9"), std::cmp::Ordering::Greater);
371    }
372
373    #[test]
374    fn current_version_nonzero() {
375        assert!(!current_version().is_empty());
376    }
377}