use std::io::Read as _;
use std::process::ExitCode;
use std::time::Duration;
const REPO: &str = "general-liquidity/sharpebench";
const SPARSE_INDEX_URL: &str = "https://index.crates.io/sh/ar/sharpebench";
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn agent() -> Result<ureq::Agent, String> {
let tls = native_tls::TlsConnector::new().map_err(|e| format!("native-tls init: {e}"))?;
Ok(ureq::builder()
.tls_connector(std::sync::Arc::new(tls))
.timeout_connect(Duration::from_millis(1500))
.timeout(Duration::from_secs(8))
.build())
}
fn parse_semver(v: &str) -> Option<(u64, u64, u64)> {
let core = v.trim().trim_start_matches('v');
let core = core.split(['-', '+']).next().unwrap_or(core);
let mut it = core.split('.');
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
let c = it.next()?.parse().ok()?;
Some((a, b, c))
}
fn is_newer(candidate: &str, current: &str) -> bool {
match (parse_semver(candidate), parse_semver(current)) {
(Some(cand), Some(cur)) => cand > cur,
_ => false,
}
}
fn latest_crate_version(agent: &ureq::Agent) -> Option<String> {
let body = agent
.get(SPARSE_INDEX_URL)
.call()
.ok()?
.into_string()
.ok()?;
let mut newest: Option<String> = None;
for line in body.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
if v.get("yanked").and_then(serde_json::Value::as_bool) == Some(true) {
continue;
}
if let Some(vers) = v.get("vers").and_then(serde_json::Value::as_str) {
if newest.as_deref().is_none_or(|n| is_newer(vers, n)) {
newest = Some(vers.to_string());
}
}
}
newest
}
fn cache_path() -> std::path::PathBuf {
std::env::temp_dir().join("sharpebench-update-check")
}
fn due_for_check() -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = cache_path();
let last = std::fs::read_to_string(&path)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(0);
if now.saturating_sub(last) < CHECK_INTERVAL_SECS {
return false;
}
let _ = std::fs::write(&path, now.to_string());
true
}
pub fn notify_if_outdated(json: bool, subcommand: Option<&str>) {
let suppressed = json
|| std::env::var_os("SHARPEBENCH_NO_UPDATE_CHECK").is_some()
|| matches!(subcommand, Some("self-update" | "update"));
if suppressed || !due_for_check() {
return;
}
let Ok(agent) = agent() else { return };
if let Some(latest) = latest_crate_version(&agent) {
if is_newer(&latest, current_version()) {
eprintln!(
"note: sharpebench {latest} is available (you have {}). \
Update with `cargo install sharpebench` or `sharpebench self-update`.",
current_version()
);
}
}
}
fn release_asset_name() -> Option<&'static str> {
if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
Some("sharpebench-x86_64-linux-musl")
} else {
None
}
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(bytes);
let mut s = String::with_capacity(64);
for b in digest {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
fn download_bytes(agent: &ureq::Agent, url: &str) -> Result<Vec<u8>, String> {
let resp = agent
.get(url)
.set("User-Agent", "sharpebench-self-update")
.call()
.map_err(|e| format!("GET {url}: {e}"))?;
let mut buf = Vec::new();
resp.into_reader()
.take(64 * 1024 * 1024)
.read_to_end(&mut buf)
.map_err(|e| format!("read {url}: {e}"))?;
Ok(buf)
}
fn replace_current_exe(bytes: &[u8]) -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| format!("locating current exe: {e}"))?;
let dir = exe
.parent()
.ok_or_else(|| "current exe has no parent dir".to_string())?;
let tmp = dir.join(".sharpebench-update.tmp");
std::fs::write(&tmp, bytes).map_err(|e| format!("writing {}: {e}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod {}: {e}", tmp.display()))?;
}
std::fs::rename(&tmp, &exe).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("replacing {}: {e}", exe.display())
})
}
fn self_update() -> Result<String, String> {
let Some(asset) = release_asset_name() else {
return Err(
"self-update only updates the published Linux static binary; on this platform \
run `cargo install sharpebench` to upgrade."
.to_string(),
);
};
let agent = agent()?;
let meta = agent
.get(&format!(
"https://api.github.com/repos/{REPO}/releases/latest"
))
.set("User-Agent", "sharpebench-self-update")
.call()
.map_err(|e| format!("querying latest release: {e}"))?
.into_string()
.map_err(|e| e.to_string())?;
let meta: serde_json::Value = serde_json::from_str(&meta).map_err(|e| e.to_string())?;
let tag = meta
.get("tag_name")
.and_then(serde_json::Value::as_str)
.ok_or("release has no tag_name")?;
if !is_newer(tag, current_version()) {
return Ok(format!(
"already up to date (have {}, latest is {tag})",
current_version()
));
}
let assets = meta
.get("assets")
.and_then(serde_json::Value::as_array)
.ok_or("release has no assets")?;
let url_of = |name: &str| -> Option<String> {
assets.iter().find_map(|a| {
(a.get("name").and_then(serde_json::Value::as_str) == Some(name))
.then(|| {
a.get("browser_download_url")
.and_then(serde_json::Value::as_str)
})
.flatten()
.map(str::to_string)
})
};
let bin_url = url_of(asset).ok_or_else(|| format!("release {tag} has no asset {asset}"))?;
let sum_url = url_of(&format!("{asset}.sha256"))
.ok_or_else(|| format!("release {tag} has no checksum for {asset}"))?;
let bin = download_bytes(&agent, &bin_url)?;
let sums = String::from_utf8(download_bytes(&agent, &sum_url)?)
.map_err(|_| "checksum file is not UTF-8".to_string())?;
let expected = sums
.split_whitespace()
.next()
.ok_or("empty checksum file")?
.to_lowercase();
let got = sha256_hex(&bin);
if got != expected {
return Err(format!(
"checksum mismatch for {asset}: expected {expected}, got {got} — refusing to install"
));
}
replace_current_exe(&bin)?;
Ok(format!("updated {} -> {tag}", current_version()))
}
pub fn run_self_update() -> ExitCode {
match self_update() {
Ok(msg) => {
println!("{msg}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("self-update failed: {e}");
ExitCode::from(1)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semver_ordering() {
assert!(is_newer("0.0.2", "0.0.1"));
assert!(is_newer("v0.1.0", "0.0.9"));
assert!(is_newer("1.0.0", "0.99.99"));
assert!(!is_newer("0.0.1", "0.0.1"));
assert!(!is_newer("0.0.1", "0.0.2"));
assert!(is_newer("0.0.2-rc1", "0.0.1"));
assert!(!is_newer("garbage", "0.0.1"));
assert!(!is_newer("0.0.2", "garbage"));
}
#[test]
fn sha256_is_lowercase_hex_of_known_vector() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn this_platform_asset_resolves_or_is_none() {
if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
assert_eq!(release_asset_name(), Some("sharpebench-x86_64-linux-musl"));
} else {
assert!(release_asset_name().is_none());
}
}
}