use crate::provider::manager as mgr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Tarball,
Brew,
Cargo,
Mise,
Other,
}
pub fn channel() -> Channel {
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
if exe.contains(".cargo/bin") {
Channel::Cargo
} else if exe.contains("Cellar") || exe.contains("homebrew") || exe.contains("linuxbrew") {
Channel::Brew
} else if exe.contains("/mise/") {
Channel::Mise
} else if exe.contains("/.local/") || exe.contains("/usr/local/") {
Channel::Tarball
} else {
Channel::Other
}
}
fn parse_version(tag: &str) -> Option<(u64, u64, u64)> {
let v = tag.strip_prefix('v').unwrap_or(tag);
let mut it = v.split('.');
Some((
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
it.next()?.parse().ok()?,
))
}
pub fn is_newer(latest: &str) -> bool {
match (
parse_version(latest),
parse_version(env!("CARGO_PKG_VERSION")),
) {
(Some(l), Some(c)) => l > c,
_ => false,
}
}
pub fn update(check_only: bool) -> Result<String, String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
update_inner("https://api.github.com", check_only, &exe, channel())
}
fn update_inner(
api_base: &str,
check_only: bool,
exe: &std::path::Path,
channel: Channel,
) -> Result<String, String> {
let current = env!("CARGO_PKG_VERSION");
let release =
mgr::latest_release_at(api_base, "rootledev/rootle").map_err(|e| e.to_string())?;
let tag = release.tag_name.clone();
if !is_newer(&tag) {
return Ok(format!("rootle {current} is current"));
}
let guidance = || {
format!(
"{current} → {tag} — you installed via {how}: run `{cmd}`",
how = match channel {
Channel::Brew => "homebrew",
Channel::Cargo => "cargo",
Channel::Mise => "mise",
_ => "your package manager",
},
cmd = match channel {
Channel::Brew => "brew upgrade rootle",
Channel::Cargo => "cargo install rootle",
Channel::Mise => "mise up rootle",
_ => "your package manager's upgrade",
}
)
};
if !matches!(channel, Channel::Tarball | Channel::Other) {
return Ok(guidance());
}
if check_only {
return Ok(format!("{current} → {tag} available (run `rootle update`)"));
}
let target = mgr::platform_target();
let asset = mgr::pick_asset(&release, target).map_err(|e| e.to_string())?;
let sidecar = mgr::checksum_sidecar(&release, asset).map_err(|e| e.to_string())?;
let tarball = mgr::download_bytes(&asset.browser_download_url).map_err(|e| e.to_string())?;
mgr::verify_checksum(&tarball, &sidecar.browser_download_url).map_err(|e| e.to_string())?;
let bytes = mgr::extract_binary(&tarball, "rootle").map_err(|e| e.to_string())?;
let staged = exe.with_extension("update-tmp");
std::fs::write(&staged, &bytes).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755));
}
std::fs::rename(&staged, exe).map_err(|e| e.to_string())?;
Ok(format!(
"{current} → {tag} (sha256 {}…) — takes effect on next launch",
&mgr::sha256_hex(&bytes)[..12]
))
}
fn cache_path() -> Option<std::path::PathBuf> {
dirs::cache_dir().map(|d| d.join("rootle").join("update.json"))
}
pub fn latest_known() -> Option<String> {
#[derive(serde::Deserialize, serde::Serialize)]
struct Stamp {
tag: String,
checked_at: u64,
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = cache_path()?;
if let Ok(text) = std::fs::read_to_string(&path)
&& let Ok(stamp) = serde_json::from_str::<Stamp>(&text)
&& now.saturating_sub(stamp.checked_at) < 24 * 3600
{
return Some(stamp.tag);
}
let tag = mgr::latest_release("rootledev/rootle").ok()?.tag_name;
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(
&path,
serde_json::to_string(&Stamp {
tag: tag.clone(),
checked_at: now,
})
.unwrap_or_default(),
);
}
Some(tag)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_ordering() {
let cur = env!("CARGO_PKG_VERSION");
let mut parts = cur.split('.').map(|p| p.parse::<u64>().unwrap());
let next = format!(
"v{}.{}.{}",
parts.next().unwrap(),
parts.next().unwrap() + 1,
parts.next().unwrap()
);
assert!(is_newer(&next));
assert!(!is_newer("v0.1.0"));
assert!(!is_newer(cur));
assert!(!is_newer("garbage"));
}
#[test]
fn tarball_update_downloads_verifies_and_swaps() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let target = crate::provider::manager::platform_target();
let file = format!("rootle-9.9.9-{target}.tar.gz");
let payload = b"#!/bin/sh\necho new rootle\n";
let enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut builder = tar::Builder::new(enc);
let mut header = tar::Header::new_gnu();
header.set_size(payload.len() as u64);
header.set_mode(0o755);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder
.append_data(&mut header, "pkg/rootle", &payload[..])
.unwrap();
let tarball = builder.into_inner().unwrap().finish().unwrap();
let sha = crate::provider::manager::sha256_hex(&tarball);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let server = rt.block_on(MockServer::start());
let base = server.uri();
rt.block_on(async {
Mock::given(method("GET"))
.and(path("/repos/rootledev/rootle/releases/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tag_name": "v9.9.9",
"assets": [
{"name": file, "browser_download_url": format!("{base}/dl/{file}")},
{"name": format!("{file}.sha256"), "browser_download_url": format!("{base}/dl/{file}.sha256")},
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/dl/{file}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(tarball.clone()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/dl/{file}.sha256")))
.respond_with(
ResponseTemplate::new(200).set_body_string(format!("{sha} {file}")),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/dl/tampered-{file}.sha256")))
.respond_with(ResponseTemplate::new(200).set_body_string("deadbeef x"))
.mount(&server)
.await;
});
let dir = std::env::temp_dir().join(format!("rootle-update-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let exe = dir.join("rootle");
std::fs::write(&exe, b"#!/bin/sh\necho old\n").unwrap();
let line = update_inner(&base, false, &exe, Channel::Tarball).expect("update");
assert!(line.contains("9.9.9"), "got: {line}");
assert_eq!(std::fs::read(&exe).unwrap(), payload, "swapped in place");
assert!(
!dir.join("rootle.update-tmp").exists(),
"staging file is renamed away"
);
let err = crate::provider::manager::verify_checksum(
b"not the tarball",
&format!("{base}/dl/{file}.sha256"),
)
.unwrap_err()
.to_string();
assert!(err.contains("checksum mismatch"), "got: {err}");
let exe3 = dir.join("rootle3");
std::fs::write(&exe3, b"#!/bin/sh\necho old\n").unwrap();
let line = update_inner(&base, true, &exe3, Channel::Tarball).expect("check");
assert!(line.contains("available"), "got: {line}");
assert_eq!(std::fs::read(&exe3).unwrap(), b"#!/bin/sh\necho old\n");
}
}