use std::env;
use std::fs;
use std::io::Write;
use std::sync::mpsc::Sender;
use std::thread::JoinHandle;
use std::time::Duration;
use serde::Deserialize;
use sha2::{Digest as _, Sha256};
const API_URL: &str = "https://api.github.com/repos/uwuclxdy/clauth/releases/latest";
const NO_UPDATE_ENV: &str = "CLAUTH_NO_UPDATE";
pub(crate) enum UpdateEvent {
Installed(String),
Available(String),
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) fn updates_enabled() -> bool {
env::var(NO_UPDATE_ENV).as_deref() != Ok("1")
}
pub(crate) fn spawn(tx: Sender<UpdateEvent>) -> Option<JoinHandle<()>> {
if !updates_enabled() {
return None;
}
#[allow(clippy::expect_used, reason = "thread spawn failure is unrecoverable")]
Some(
std::thread::Builder::new()
.name("clauth-upd".into())
.spawn(move || {
let _ = try_update(&tx);
})
.expect("failed to spawn update thread"),
)
}
fn try_update(tx: &Sender<UpdateEvent>) -> anyhow::Result<()> {
let release = fetch_latest()?;
if !is_newer(&release.tag_name, CURRENT_VERSION) {
return Ok(());
}
let version = release.tag_name.trim_start_matches('v').to_string();
let Some(asset) = asset_name() else {
let _ = tx.send(UpdateEvent::Available(version));
return Ok(());
};
if is_cargo_installed() {
let _ = tx.send(UpdateEvent::Available(version));
return Ok(());
}
let Some(url) = release
.assets
.iter()
.find(|a| a.name == asset)
.map(|a| a.browser_download_url.clone())
else {
return Ok(());
};
let sums_url = derive_sums_url(&url, asset);
download_and_replace(&url, &sums_url, asset)?;
let _ = tx.send(UpdateEvent::Installed(version));
Ok(())
}
fn derive_sums_url(asset_url: &str, asset: &str) -> String {
if let Some(idx) = asset_url.rfind(asset) {
format!("{}sha256sums.txt", &asset_url[..idx])
} else {
format!("{asset_url}/sha256sums.txt")
}
}
pub(crate) fn parse_sums_line(line: &str) -> Option<(&str, &str)> {
let (hex, rest) = line.split_once(" ")?;
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some((hex, rest.trim_end()))
}
pub(crate) fn find_expected_sha(sums_text: &str, asset: &str) -> Option<String> {
sums_text.lines().find_map(|line| {
let (hex, name) = parse_sums_line(line)?;
(name == asset).then(|| hex.to_owned())
})
}
pub(crate) fn verify_sha256(bytes: &[u8], expected_hex: &str) -> bool {
if expected_hex.len() != 64 {
return false;
}
let digest = Sha256::digest(bytes);
let actual = digest.iter().fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
s
});
actual == expected_hex.to_ascii_lowercase()
}
fn make_agent() -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_response(Some(Duration::from_secs(30)))
.build()
.into()
}
fn fetch_latest() -> anyhow::Result<Release> {
let text = make_agent()
.get(API_URL)
.header("User-Agent", "clauth-updater")
.call()
.map_err(anyhow::Error::from)?
.body_mut()
.read_to_string()
.map_err(anyhow::Error::from)?;
Ok(serde_json::from_str(&text)?)
}
fn download_and_replace(url: &str, sums_url: &str, asset: &str) -> anyhow::Result<()> {
let agent = make_agent();
let sums_text = agent
.get(sums_url)
.header("User-Agent", "clauth-updater")
.call()
.map_err(|e| anyhow::anyhow!("sha256sums.txt fetch failed (skipping update): {e}"))?
.body_mut()
.read_to_string()
.map_err(|e| anyhow::anyhow!("sha256sums.txt read failed (skipping update): {e}"))?;
let expected_hex = find_expected_sha(&sums_text, asset)
.ok_or_else(|| anyhow::anyhow!("asset {asset} not listed in sha256sums.txt — aborting"))?;
let bytes = agent
.get(url)
.header("User-Agent", "clauth-updater")
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?
.body_mut()
.read_to_vec()
.map_err(|e| anyhow::anyhow!("{e}"))?;
if !verify_sha256(&bytes, &expected_hex) {
anyhow::bail!(
"SHA-256 mismatch for {asset}: download corrupted or tampered — aborting update"
);
}
let tmp_path = env::temp_dir().join("clauth_update.tmp");
let _ = fs::remove_file(&tmp_path);
{
let mut f = fs::File::create(&tmp_path)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))?;
}
self_replace::self_replace(&tmp_path)?;
let _ = fs::remove_file(&tmp_path);
Ok(())
}
fn is_cargo_installed() -> bool {
let Ok(exe) = env::current_exe() else {
return false;
};
let Some(home) = dirs::home_dir() else {
return false;
};
exe.starts_with(home.join(".cargo").join("bin"))
}
fn asset_name() -> Option<&'static str> {
if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
Some("clauth-linux-x86_64")
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
Some("clauth-macos-x86_64")
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Some("clauth-macos-aarch64")
} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
Some("clauth-windows-x86_64.exe")
} else {
None
}
}
fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
let v = v.trim_start_matches('v');
let mut it = v.splitn(3, '.');
Some((
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
))
}
fn is_newer(tag: &str, current: &str) -> bool {
match (parse_version(tag), parse_version(current)) {
(Some(latest), Some(cur)) => latest > cur,
_ => false,
}
}
#[cfg(test)]
#[path = "../tests/inline/update.rs"]
mod tests;