use clap::Args;
use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
const UPDATE_GUIDE: &str = include_str!("../UPDATE_GUIDE.md");
const RELEASES_API: &str =
"https://gitlab.com/api/v4/projects/SpicyDogWings%2Fhefesto/releases?per_page=1";
const ASSET_NAME: &str = "pandora";
const CHECKSUM_ASSET: &str = "pandora.sha256";
#[derive(Args)]
pub struct UpdateArgs {
#[arg(short, long)]
pub force: bool,
#[arg(long)]
pub guide: bool,
}
pub fn run(args: UpdateArgs) {
if args.guide {
println!("{}", UPDATE_GUIDE);
return;
}
let current = env!("CARGO_PKG_VERSION");
println!("Current version: {current}");
let body = match fetch(RELEASES_API) {
Ok(b) => b,
Err(e) => {
eprintln!("error: could not fetch releases: {e}");
std::process::exit(1);
}
};
let releases: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
eprintln!("error: invalid release JSON: {e}");
std::process::exit(1);
}
};
let release = match releases.as_array().and_then(|a| a.first()) {
Some(r) => r,
None => {
eprintln!("error: no releases found");
std::process::exit(1);
}
};
let tag = release["tag_name"].as_str().unwrap_or("").trim_start_matches('v');
if tag.is_empty() {
eprintln!("error: release has no tag_name");
std::process::exit(1);
}
println!("Latest version: {tag}");
if tag == current && !args.force {
println!("Already up to date.");
return;
}
let links = release["assets"]["links"].as_array();
let url = match links
.and_then(|ls| ls.iter().find(|l| l["name"] == ASSET_NAME))
.and_then(|l| l["direct_asset_url"].as_str())
{
Some(u) => u,
None => {
eprintln!("error: asset '{ASSET_NAME}' not found in release {tag}");
std::process::exit(1);
}
};
let sha_url = match links
.and_then(|ls| ls.iter().find(|l| l["name"] == CHECKSUM_ASSET))
.and_then(|l| l["direct_asset_url"].as_str())
{
Some(u) => u,
None => {
eprintln!("error: checksum asset '{CHECKSUM_ASSET}' not found in release {tag}");
std::process::exit(1);
}
};
let exe_path = match env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("error: could not determine binary path: {e}");
std::process::exit(1);
}
};
let tmp_path = exe_path.with_extension("new");
println!("Downloading...");
let status = Command::new("curl")
.args(["-fsSL", "-o"])
.arg(&tmp_path)
.arg(url)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("error: download failed (curl exit {})", s.code().unwrap_or(-1));
std::process::exit(1);
}
Err(e) => {
eprintln!("error: could not run curl: {e}");
std::process::exit(1);
}
}
let expected = match fetch(sha_url) {
Ok(s) => s.split_whitespace().next().unwrap_or("").to_string(),
Err(e) => {
eprintln!("error: could not fetch checksum: {e}");
let _ = fs::remove_file(&tmp_path);
std::process::exit(1);
}
};
if expected.is_empty() {
eprintln!("error: invalid checksum format");
let _ = fs::remove_file(&tmp_path);
std::process::exit(1);
}
let actual = sha256_digest(&tmp_path).unwrap_or_else(|e| {
eprintln!("error: could not compute checksum: {e}");
let _ = fs::remove_file(&tmp_path);
std::process::exit(1);
});
if expected != actual {
eprintln!("error: checksum mismatch (expected {expected}, got {actual})");
let _ = fs::remove_file(&tmp_path);
std::process::exit(1);
}
println!("Checksum verified.");
let mut perms = match fs::metadata(&tmp_path) {
Ok(m) => m.permissions(),
Err(e) => {
eprintln!("error: could not stat downloaded file: {e}");
let _ = fs::remove_file(&tmp_path);
std::process::exit(1);
}
};
perms.set_mode(0o755);
if let Err(e) = fs::set_permissions(&tmp_path, perms) {
eprintln!("error: could not chmod: {e}");
let _ = fs::remove_file(&tmp_path);
std::process::exit(1);
}
if let Err(e) = fs::rename(&tmp_path, &exe_path) {
eprintln!("error: could not replace binary at {}: {e}", exe_path.display());
eprintln!("hint: run with sudo if installed in a system directory");
let _ = fs::remove_file(&tmp_path);
std::process::exit(1);
}
println!("Updated to {tag}");
}
fn sha256_digest(path: &std::path::Path) -> Result<String, String> {
let output = Command::new("sha256sum")
.arg(path)
.output()
.map_err(|e| format!("could not run sha256sum: {e}"))?;
if !output.status.success() {
return Err("sha256sum failed".into());
}
String::from_utf8(output.stdout)
.map_err(|e| format!("non-utf8 sha256sum output: {e}"))
.map(|s| s.split_whitespace().next().unwrap_or("").to_string())
}
fn fetch(url: &str) -> Result<String, String> {
let output = Command::new("curl")
.args(["-fsSL", url])
.output()
.map_err(|e| format!("could not run curl: {e}"))?;
if !output.status.success() {
return Err(format!("curl exited with code {}", output.status.code().unwrap_or(-1)));
}
String::from_utf8(output.stdout).map_err(|e| format!("non-utf8 response: {e}"))
}