use std::path::PathBuf;
use std::process::Command;
fn cache_file() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|h| PathBuf::from(h).join(".cache"))
})?;
Some(base.join("sinter").join("latest-release"))
}
fn parse_semver(v: &str) -> Option<(u64, u64, u64)> {
let mut it = v.trim().trim_start_matches('v').splitn(3, '.');
Some((
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
))
}
pub fn cached_newer() -> Option<String> {
let cached = std::fs::read_to_string(cache_file()?).ok()?;
let latest = parse_semver(&cached)?;
let running = parse_semver(env!("CARGO_PKG_VERSION"))?;
(latest > running).then(|| cached.trim().to_string())
}
fn fetch_latest_tag() -> Option<String> {
let out = Command::new("curl")
.args([
"-sI",
"--max-time",
"4",
"https://github.com/shellfu/sinter/releases/latest",
])
.output()
.ok()?;
let headers = String::from_utf8_lossy(&out.stdout);
headers
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("location:"))
.and_then(|l| l.rsplit('/').next())
.map(str::trim)
.filter(|t| parse_semver(t).is_some())
.map(str::to_string)
}
fn write_cache(tag: &str) {
let Some(path) = cache_file() else { return };
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&path, format!("{tag}\n"));
}
pub fn nudge_due() -> bool {
let Some(stamp) = cache_file().map(|p| p.with_file_name("nudge-stamp")) else {
return true;
};
std::fs::metadata(&stamp)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_none_or(|age| age.as_secs() >= 3600)
}
pub fn mark_nudged() {
let Some(stamp) = cache_file().map(|p| p.with_file_name("nudge-stamp")) else {
return;
};
if let Some(dir) = stamp.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&stamp, b"");
}
pub fn refresh_cache() {
if std::env::var_os("SINTER_NO_UPDATE_CHECK").is_some() {
return;
}
let Some(path) = cache_file() else { return };
let fresh = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age.as_secs() < 24 * 3600);
if fresh {
return;
}
if let Some(tag) = fetch_latest_tag() {
write_cache(&tag);
}
}
fn target_for(os: &str, arch: &str) -> Option<String> {
let suffix = match os {
"linux" => "unknown-linux-musl",
"macos" => "apple-darwin",
"windows" => "pc-windows-msvc",
_ => return None,
};
matches!(arch, "x86_64" | "aarch64").then(|| format!("{arch}-{suffix}"))
}
fn parse_checksum_line(line: &str, asset: &str) -> Option<String> {
let mut it = line.split_whitespace();
let hash = it.next()?;
let name = it.next()?;
(name == asset && hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()))
.then(|| hash.to_ascii_lowercase())
}
fn sha256_of(path: &std::path::Path) -> anyhow::Result<String> {
let attempts: &[(&str, &[&str])] = if cfg!(windows) {
&[("certutil", &["-hashfile"])]
} else {
&[("sha256sum", &[]), ("shasum", &["-a", "256"])]
};
for (cmd, args) in attempts {
let mut c = Command::new(cmd);
c.args(*args).arg(path);
if cfg!(windows) {
c.arg("SHA256");
}
let Ok(out) = c.output() else { continue };
if !out.status.success() {
continue;
}
let text = String::from_utf8_lossy(&out.stdout);
let hash = text
.split_whitespace()
.find(|w| w.len() == 64 && w.chars().all(|c| c.is_ascii_hexdigit()));
if let Some(h) = hash {
return Ok(h.to_ascii_lowercase());
}
}
anyhow::bail!(
"cannot verify the downloaded release: no sha256 tool found (need sha256sum, shasum, or certutil)"
)
}
const INSTALL_HINT: &str = if cfg!(windows) {
"irm https://raw.githubusercontent.com/shellfu/sinter/main/scripts/install.ps1 | iex"
} else {
"curl -fsSL https://raw.githubusercontent.com/shellfu/sinter/main/scripts/install.sh | sh"
};
fn download(url: &str, dest: &std::path::Path) -> anyhow::Result<()> {
use anyhow::Context;
let status = Command::new("curl")
.arg("-fsSL")
.arg("--proto")
.arg("=https")
.arg("-o")
.arg(dest)
.arg(url)
.status()
.context("run curl")?;
anyhow::ensure!(status.success(), "download failed: {url}");
Ok(())
}
fn replace_exe(new_bin: &std::path::Path, exe: &std::path::Path) -> anyhow::Result<()> {
use anyhow::Context;
let dir = exe.parent().context("current_exe has no parent")?;
let staged = dir.join(format!(".sinter-update-{}", std::process::id()));
let writable_hint = || {
format!(
"{} is not writable — reinstall with: {INSTALL_HINT}",
dir.display()
)
};
std::fs::copy(new_bin, &staged).with_context(writable_hint)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
}
let result = (|| -> anyhow::Result<()> {
#[cfg(windows)]
{
let old = dir.join("sinter.exe.old");
let _ = std::fs::remove_file(&old);
std::fs::rename(exe, &old).with_context(writable_hint)?;
}
std::fs::rename(&staged, exe).with_context(writable_hint)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&staged);
}
result
}
pub fn run(dry_run: bool) -> anyhow::Result<()> {
use anyhow::Context;
if std::env::var_os("SINTER_NO_UPDATE_CHECK").is_some() {
anyhow::bail!(
"update check is disabled (SINTER_NO_UPDATE_CHECK is set) — unset it to use `sinter update`"
);
}
if cfg!(windows)
&& let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
let _ = std::fs::remove_file(dir.join("sinter.exe.old"));
}
let latest = if dry_run {
cache_file()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim().to_string())
.filter(|t| parse_semver(t).is_some())
.context("no cached release info — run `sinter update` without --dry-run")?
} else {
let tag = fetch_latest_tag()
.context("could not determine the latest release (is github.com reachable?)")?;
write_cache(&tag);
tag
};
let running = env!("CARGO_PKG_VERSION");
if parse_semver(&latest) <= parse_semver(running) {
println!("sinter {running} is current");
return Ok(());
}
let target = target_for(std::env::consts::OS, std::env::consts::ARCH)
.with_context(|| {
format!(
"no prebuilt release for {}-{} — build from source: cargo install --git https://github.com/shellfu/sinter sinter-cli",
std::env::consts::ARCH,
std::env::consts::OS
)
})?;
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
let asset = format!("sinter-{target}.{ext}");
let url = format!("https://github.com/shellfu/sinter/releases/latest/download/{asset}");
let exe = std::env::current_exe().context("locate current executable")?;
if dry_run {
println!("sinter {running} → {latest}");
println!("would download {url}");
println!("would verify {asset}.sha256 and replace {}", exe.display());
return Ok(());
}
let tmp = cache_file()
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
.context("cannot locate a user cache directory for staging")?
.join(format!("update-{}", std::process::id()));
if let Some(parent) = tmp.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::create_dir(&tmp).with_context(|| {
format!(
"staging dir {} already exists — remove it and retry",
tmp.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o700))?;
}
let result = (|| -> anyhow::Result<()> {
let archive = tmp.join(&asset);
println!("downloading {asset} ...");
download(&url, &archive)?;
download(&format!("{url}.sha256"), &tmp.join("expected.sha256"))?;
let expected_line = std::fs::read_to_string(tmp.join("expected.sha256"))?;
let expected = parse_checksum_line(&expected_line, &asset)
.with_context(|| format!("malformed checksum file: {expected_line:?}"))?;
anyhow::ensure!(
sha256_of(&archive)? == expected,
"checksum mismatch for {asset} — refusing to install"
);
let status = Command::new("tar")
.arg("-xf")
.arg(&archive)
.arg("-C")
.arg(&tmp)
.status()
.context("run tar")?;
anyhow::ensure!(status.success(), "could not extract {asset}");
let bin = tmp.join(if cfg!(windows) {
"sinter.exe"
} else {
"sinter"
});
anyhow::ensure!(bin.is_file(), "archive did not contain a sinter binary");
replace_exe(&bin, &exe)?;
println!("sinter {running} → {latest}");
println!("updated {}", exe.display());
Ok(())
})();
let _ = std::fs::remove_dir_all(&tmp);
result
}
#[cfg(test)]
mod tests {
use super::{parse_checksum_line, parse_semver, target_for};
#[test]
fn target_selection_covers_the_release_matrix() {
assert_eq!(
target_for("linux", "x86_64").as_deref(),
Some("x86_64-unknown-linux-musl")
);
assert_eq!(
target_for("linux", "aarch64").as_deref(),
Some("aarch64-unknown-linux-musl")
);
assert_eq!(
target_for("macos", "x86_64").as_deref(),
Some("x86_64-apple-darwin")
);
assert_eq!(
target_for("macos", "aarch64").as_deref(),
Some("aarch64-apple-darwin")
);
assert_eq!(
target_for("windows", "x86_64").as_deref(),
Some("x86_64-pc-windows-msvc")
);
assert_eq!(
target_for("windows", "aarch64").as_deref(),
Some("aarch64-pc-windows-msvc")
);
assert_eq!(target_for("freebsd", "x86_64"), None);
assert_eq!(target_for("linux", "riscv64"), None);
}
#[test]
fn checksum_line_parses_and_rejects() {
let hash = "a".repeat(64);
let asset = "sinter-x86_64-unknown-linux-musl.tar.gz";
assert_eq!(
parse_checksum_line(&format!("{hash} {asset}\n"), asset).as_deref(),
Some(hash.as_str())
);
assert_eq!(
parse_checksum_line(&format!("{} {asset}", hash.to_uppercase()), asset).as_deref(),
Some(hash.as_str())
);
assert_eq!(
parse_checksum_line(&format!("{hash} other.tar.gz"), asset),
None
);
assert_eq!(
parse_checksum_line(&format!("abc123 {asset}"), asset),
None
);
assert_eq!(
parse_checksum_line(&format!("{} {asset}", "z".repeat(64)), asset),
None
);
assert_eq!(parse_checksum_line("", asset), None);
}
#[test]
fn semver_parses_and_orders() {
assert_eq!(parse_semver("v0.36.0"), Some((0, 36, 0)));
assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
assert!(parse_semver("v0.36.0") < parse_semver("v0.36.1"));
assert!(parse_semver("v0.9.0") < parse_semver("v0.36.0"));
assert_eq!(parse_semver("latest"), None);
assert_eq!(parse_semver(""), None);
}
}