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 API_LATEST: &str = "https://api.github.com/repos/theesfeld/f00/releases/latest";
14
15pub fn current_version() -> &'static str {
17 env!("CARGO_PKG_VERSION")
18}
19
20pub fn host_target() -> Result<&'static str> {
22 #[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
75pub 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
93pub 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#[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(¤t, &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(¤t).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 #[cfg(unix)]
275 {
276 fs::rename(¤t, &backup).context("backup current binary")?;
277 if let Err(e) = fs::rename(&staged, ¤t) {
278 let _ = fs::rename(&backup, ¤t);
280 return Err(e).context("install new binary");
281 }
282 let _ = fs::remove_file(&backup);
283 }
284
285 #[cfg(windows)]
286 {
287 let final_path = current.clone();
289 if let Err(e) = fs::rename(&staged, &final_path) {
290 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
300pub fn perform_update() -> Result<(String, String)> {
302 let current = current_version().to_string();
303 let rel = latest_release()?;
304 if cmp_version(¤t, &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
338pub 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}