use crate::config;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::{Path, PathBuf};
const RELEASES_URL: &str = "https://api.github.com/repos/flolep2607/cctop/releases/latest";
const USER_AGENT: &str = concat!("cctop/", env!("CARGO_PKG_VERSION"));
const CHECK_MAX_AGE_SECS: u64 = 24 * 60 * 60;
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn asset_target() -> Option<&'static str> {
Some(match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "x86_64-unknown-linux-musl",
("linux", "aarch64") => "aarch64-unknown-linux-musl",
("macos", "x86_64") => "x86_64-apple-darwin",
("macos", "aarch64") => "aarch64-apple-darwin",
("windows", "x86_64") => "x86_64-pc-windows-msvc",
_ => return None,
})
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Serialize, Deserialize)]
struct CheckCache {
checked_at: u64,
latest: String,
}
fn unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(15)))
.user_agent(USER_AGENT)
.build()
.into()
}
fn is_newer(candidate: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<u64> {
v.trim()
.trim_start_matches('v')
.split(['-', '+'])
.next()
.unwrap_or_default()
.split('.')
.map(|p| p.parse().unwrap_or(0))
.collect()
}
let (a, b) = (parts(candidate), parts(current));
let len = a.len().max(b.len());
for i in 0..len {
let (x, y) = (
a.get(i).copied().unwrap_or(0),
b.get(i).copied().unwrap_or(0),
);
if x != y {
return x > y;
}
}
false
}
fn fetch_latest() -> Result<Release> {
let text = agent()
.get(RELEASES_URL)
.call()
.context("could not reach GitHub")?
.body_mut()
.read_to_string()
.context("could not read the release response")?;
serde_json::from_str(&text).context("could not parse the release response")
}
pub fn cached_latest_version() -> Option<String> {
let path = config::CACHE_DIR.join("update-check.json");
if let Ok(text) = std::fs::read_to_string(&path)
&& let Ok(cache) = serde_json::from_str::<CheckCache>(&text)
&& unix_secs().saturating_sub(cache.checked_at) < CHECK_MAX_AGE_SECS
{
return Some(cache.latest);
}
let latest = fetch_latest()
.ok()?
.tag_name
.trim_start_matches('v')
.to_string();
let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
if let Ok(text) = serde_json::to_string(&CheckCache {
checked_at: unix_secs(),
latest: latest.clone(),
}) {
let _ = std::fs::write(&path, text);
}
Some(latest)
}
pub fn available_update() -> Option<String> {
let latest = cached_latest_version()?;
is_newer(&latest, current_version()).then_some(latest)
}
fn unpack(archive: &[u8], target: &str, into: &Path) -> Result<PathBuf> {
let binary_name = if target.contains("windows") {
"cctop.exe"
} else {
"cctop"
};
let out = into.join(binary_name);
if target.contains("windows") {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive))
.context("release archive is not a valid zip")?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let is_binary = Path::new(entry.name())
.file_name()
.is_some_and(|n| n == binary_name);
if is_binary {
let mut file = std::fs::File::create(&out)?;
std::io::copy(&mut entry, &mut file)?;
return Ok(out);
}
}
} else {
let decoder = flate2::read::GzDecoder::new(archive);
let mut tar = tar::Archive::new(decoder);
for entry in tar
.entries()
.context("release archive is not a valid tar")?
{
let mut entry = entry?;
let is_binary = entry.path()?.file_name().is_some_and(|n| n == binary_name);
if is_binary {
let mut file = std::fs::File::create(&out)?;
std::io::copy(&mut entry, &mut file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))?;
}
return Ok(out);
}
}
}
bail!("the release archive contains no {binary_name}")
}
pub fn run(force: bool) -> Result<()> {
let current = current_version();
let target =
asset_target().ok_or_else(|| anyhow!("no release is published for this platform"))?;
println!("Current version {current}; checking for updates…");
let release = fetch_latest()?;
let latest = release.tag_name.trim_start_matches('v');
if !is_newer(latest, current) && !force {
println!("Already on the newest version ({current}).");
return Ok(());
}
let asset = release
.assets
.iter()
.find(|a| {
a.name.contains(target) && (a.name.ends_with(".tar.gz") || a.name.ends_with(".zip"))
})
.ok_or_else(|| anyhow!("release {latest} has no archive for {target}"))?;
let staging = staging_dir()?;
println!("Downloading {}…", asset.name);
let mut body = Vec::new();
agent()
.get(&asset.browser_download_url)
.call()
.context("could not download the release archive")?
.body_mut()
.as_reader()
.read_to_end(&mut body)
.context("could not read the release archive")?;
let new_binary = unpack(&body, target, staging.path())?;
self_replace::self_replace(&new_binary).context("could not replace the running executable")?;
println!("Updated {current} -> {latest}.");
Ok(())
}
#[cfg(unix)]
const ELEVATE: &str = "re-run it as `sudo cctop --update`";
#[cfg(not(unix))]
const ELEVATE: &str = "re-run `cctop --update` from an elevated prompt";
fn staging_dir() -> Result<tempfile::TempDir> {
let exe = std::env::current_exe().context("could not locate the running executable")?;
let dir = exe
.parent()
.ok_or_else(|| anyhow!("the running executable has no parent directory"))?;
stage_in(dir)
}
fn stage_in(dir: &Path) -> Result<tempfile::TempDir> {
tempfile::Builder::new()
.prefix(".cctop-update-")
.tempdir_in(dir)
.map_err(|error| {
if error.kind() == std::io::ErrorKind::PermissionDenied {
anyhow!(
"{} is not writable by this user, so the new binary cannot replace the old one: {ELEVATE}. \
If a package manager installed cctop, update it with that instead.",
dir.display()
)
} else {
anyhow::Error::new(error)
.context(format!("could not stage an update in {}", dir.display()))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn an_unwritable_install_directory_names_the_fix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
let error = match stage_in(dir.path()) {
Err(error) => format!("{error:#}"),
Ok(_) => return,
};
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(error.contains("sudo cctop --update"), "got: {error}");
assert!(
error.contains(&dir.path().display().to_string()),
"got: {error}"
);
}
#[test]
fn version_ordering_only_moves_forward() {
assert!(is_newer("0.1.8", "0.1.7"));
assert!(is_newer("v0.2.0", "0.1.9"));
assert!(is_newer("1.0.0", "0.9.9"));
assert!(!is_newer("0.1.7", "0.1.7"));
assert!(!is_newer("0.1.6", "0.1.7"));
assert!(is_newer("0.1.7.1", "0.1.7"));
assert!(!is_newer("0.1.7", "0.1.7.1"));
assert!(!is_newer("0.1.7-rc1", "0.1.7"));
assert!(!is_newer("not-a-version", "0.1.7"));
assert!(!is_newer("", "0.1.7"));
}
#[test]
fn every_released_target_is_reachable() {
if matches!(std::env::consts::ARCH, "x86_64" | "aarch64") {
assert!(asset_target().is_some(), "no asset for this platform");
}
}
#[test]
fn unpack_takes_only_the_executable_from_a_tarball() {
let mut tar = tar::Builder::new(Vec::new());
let payload = b"#!/bin/sh\necho hi\n";
for name in ["README.md", "dist/nested/cctop"] {
let mut header = tar::Header::new_gnu();
header.set_size(payload.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(&mut header.clone(), name, &payload[..])
.unwrap();
}
let raw = tar.into_inner().unwrap();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
std::io::Write::write_all(&mut gz, &raw).unwrap();
let archive = gz.finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let out = unpack(&archive, "x86_64-unknown-linux-musl", dir.path()).unwrap();
assert_eq!(out, dir.path().join("cctop"));
assert_eq!(std::fs::read(&out).unwrap(), payload);
assert!(!dir.path().join("dist").exists());
assert!(!dir.path().join("README.md").exists());
}
#[test]
fn unpack_reports_an_archive_without_the_binary() {
let mut tar = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_size(3);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, "README.md", &b"hi\n"[..])
.unwrap();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
std::io::Write::write_all(&mut gz, &tar.into_inner().unwrap()).unwrap();
let archive = gz.finish().unwrap();
let dir = tempfile::tempdir().unwrap();
assert!(unpack(&archive, "x86_64-unknown-linux-musl", dir.path()).is_err());
}
}