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 RELEASE_LIST_URL: &str = "https://api.github.com/repos/flolep2607/cctop/releases?per_page=30";
const USER_AGENT: &str = concat!("cctop/", env!("CARGO_PKG_VERSION"));
const CHECK_MAX_AGE_SECS: u64 = 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>,
#[serde(default)]
body: Option<String>,
}
impl Release {
fn version(&self) -> &str {
self.tag_name.trim_start_matches('v')
}
}
#[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}")
}
fn selected<'a>(releases: &'a [Release], from: &str, to: &str) -> Vec<&'a Release> {
let mut picked: Vec<&Release> = releases
.iter()
.filter(|r| is_newer(r.version(), from))
.filter(|r| !is_newer(r.version(), to))
.collect();
picked.sort_by(|a, b| match is_newer(a.version(), b.version()) {
true => std::cmp::Ordering::Greater,
false => std::cmp::Ordering::Less,
});
picked
}
fn bullets(body: &str) -> Vec<String> {
let mut items: Vec<String> = Vec::new();
let mut current: Option<String> = None;
for line in body.lines().map(str::trim) {
if line.is_empty() {
items.extend(current.take());
continue;
}
if line.starts_with('#') || line.starts_with("**Full Changelog**") {
items.extend(current.take());
continue;
}
match line.starts_with(['*', '-', '•']) {
true => {
items.extend(current.take());
current = Some(line.trim_start_matches(['*', '-', '•', ' ']).to_string());
}
false => match current.as_mut() {
Some(item) => {
item.push(' ');
item.push_str(line);
}
None => current = Some(line.to_string()),
},
}
}
items.extend(current);
items
.into_iter()
.map(|item| {
item.split(" by @")
.next()
.unwrap_or(&item)
.trim()
.to_string()
})
.map(|item| item.replace("**", ""))
.map(|item| strip_release_prefix(&item))
.filter(|item| !item.is_empty())
.collect()
}
fn strip_release_prefix(item: &str) -> String {
let candidate = item.strip_prefix("release ").unwrap_or(item);
let Some((version, rest)) = candidate.split_once(": ") else {
return item.to_string();
};
let numeric = !version.is_empty()
&& version
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == 'v');
match numeric && !rest.trim().is_empty() {
true => rest.trim().to_string(),
false => item.to_string(),
}
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
lines.push(line);
}
lines
}
fn show_changes(from: &str, to: &str) {
if !is_newer(to, from) {
return;
}
let notes = fetch_release_list().map(|list| {
selected(&list, from, to)
.into_iter()
.map(|r| {
(
r.version().to_string(),
bullets(r.body.as_deref().unwrap_or_default()),
)
})
.collect::<Vec<_>>()
});
let Ok(notes) = notes else {
println!("Release notes: {RELEASE_PAGE}");
return;
};
if notes.iter().all(|(_, lines)| lines.is_empty()) {
println!("Release notes: {RELEASE_PAGE}");
return;
}
let room = crossterm::terminal::size()
.map(|(cols, _)| usize::from(cols))
.unwrap_or(80)
.clamp(40, 100)
.saturating_sub(6);
println!();
println!("What changed since {from}:");
for (version, lines) in ¬es {
println!();
println!(" {version}");
match lines.is_empty() {
true => println!(" (no notes published)"),
false => {
for line in lines {
let mut wrapped = wrap(line, room).into_iter();
if let Some(first) = wrapped.next() {
println!(" - {first}");
}
for rest in wrapped {
println!(" {rest}");
}
}
}
}
}
println!();
println!("Full notes: {RELEASE_PAGE}");
}
const RELEASE_PAGE: &str = "https://github.com/flolep2607/cctop/releases";
fn fetch_release_list() -> Result<Vec<Release>> {
let text = agent()
.get(RELEASE_LIST_URL)
.call()
.context("could not reach GitHub")?
.body_mut()
.read_to_string()
.context("could not read the release list")?;
serde_json::from_str(&text).context("could not parse the release list")
}
pub fn run(force: bool) -> Result<()> {
let current = current_version();
if managed_by_cargo() {
return Err(cargo_managed());
}
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}.");
show_changes(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"))?;
match raw_stage_in(dir) {
Ok(staged) => Ok(staged),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => Err(elevate(dir)),
Err(error) => Err(anyhow::Error::new(error)
.context(format!("could not stage an update in {}", dir.display()))),
}
}
fn raw_stage_in(dir: &Path) -> std::io::Result<tempfile::TempDir> {
tempfile::Builder::new()
.prefix(".cctop-update-")
.tempdir_in(dir)
}
fn unwritable(dir: &Path) -> anyhow::Error {
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()
)
}
fn read_only(dir: &Path) -> anyhow::Error {
anyhow!(
"{} is not writable even as root, so the new binary cannot replace the old one — \
the filesystem is mounted read-only, or the binary is immutable. \
If a package manager installed cctop, update it with that instead.",
dir.display()
)
}
fn cargo_bin() -> Option<PathBuf> {
let home = match std::env::var_os("CARGO_HOME") {
Some(dir) => PathBuf::from(dir),
None => dirs::home_dir()?.join(".cargo"),
};
Some(home.join("bin"))
}
fn under(exe: &Path, bin: &Path) -> bool {
let (Ok(exe), Ok(bin)) = (exe.canonicalize(), bin.canonicalize()) else {
return false;
};
exe.parent() == Some(bin.as_path())
}
fn managed_by_cargo() -> bool {
let (Ok(exe), Some(bin)) = (std::env::current_exe(), cargo_bin()) else {
return false;
};
under(&exe, &bin)
}
fn cargo_managed() -> anyhow::Error {
anyhow!(
"cctop was installed by cargo, so replacing the binary here would put it out of step \
with what cargo has recorded: `cargo install --list` would go on reporting {}, and the \
next `cargo install-update` would undo the update. Run `cargo install cctop --force` \
instead.",
current_version()
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Recourse {
Ask,
Explain,
Privileged,
}
fn recourse(root: bool, elevated: bool, sudo: bool, interactive: bool) -> Recourse {
match (root, elevated, sudo && interactive) {
(true, _, _) => Recourse::Privileged,
(false, false, true) => Recourse::Ask,
_ => Recourse::Explain,
}
}
#[cfg(unix)]
fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
fn is_root() -> bool {
false
}
fn already_elevated() -> bool {
std::env::var_os("SUDO_USER").is_some()
}
fn sudo_argv(exe: &Path) -> Vec<String> {
vec![
"sudo".to_string(),
"--".to_string(),
exe.to_string_lossy().into_owned(),
"--update".to_string(),
]
}
fn elevate(dir: &Path) -> anyhow::Error {
match recourse(
is_root(),
already_elevated(),
crate::shim::is_command("sudo"),
interactive(),
) {
Recourse::Privileged => return read_only(dir),
Recourse::Explain => return unwritable(dir),
Recourse::Ask => {}
}
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(_) => return unwritable(dir),
};
if !confirm(dir, &exe) {
return anyhow!(
"Not updated: {} is not writable by this user. \
If a package manager installed cctop, update it with that instead.",
dir.display()
);
}
let argv = sudo_argv(&exe);
match std::process::Command::new(&argv[0])
.args(&argv[1..])
.status()
{
Ok(status) => std::process::exit(status.code().unwrap_or(1)),
Err(error) => anyhow!("could not run sudo ({error}): {ELEVATE}."),
}
}
fn interactive() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
fn confirm(dir: &Path, exe: &Path) -> bool {
use std::io::Write;
let mut err = std::io::stderr();
let _ = write!(
err,
"{} is not writable by this user.\nRe-run as root to replace {}? [y/N] ",
dir.display(),
exe.display()
);
let _ = err.flush();
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
return false;
}
matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
#[cfg(test)]
mod tests {
use super::*;
fn release(tag: &str, body: &str) -> Release {
Release {
tag_name: tag.to_string(),
assets: Vec::new(),
body: Some(body.to_string()),
}
}
#[test]
fn the_notes_cover_the_versions_actually_crossed() {
let list = [
release("v0.7.4", "later"),
release("v0.7.3", "third"),
release("v0.7.2", "second"),
release("v0.7.1", "first"),
release("v0.7.0", "already had this"),
];
let picked: Vec<&str> = selected(&list, "0.7.0", "0.7.3")
.iter()
.map(|r| r.version())
.collect();
assert_eq!(picked, ["0.7.1", "0.7.2", "0.7.3"]);
let picked: Vec<&str> = selected(&list, "0.7.2", "0.7.3")
.iter()
.map(|r| r.version())
.collect();
assert_eq!(picked, ["0.7.3"]);
}
#[test]
fn a_bullet_does_not_repeat_the_version_above_it() {
assert_eq!(
bullets("* release 0.7.3: the signals stop getting lost"),
["the signals stop getting lost"]
);
assert_eq!(
bullets("* 0.7.0: shared tabs, and a report"),
["shared tabs, and a report"]
);
assert_eq!(bullets("* release 0.7.3"), ["release 0.7.3"]);
assert_eq!(
bullets("* fix: the login hint for a named account"),
["fix: the login hint for a named account"]
);
}
#[test]
fn a_generated_release_body_becomes_one_line() {
let body = "## What's Changed\n\
* Fix the login hint for a named account, and add a run skill that \
drives the TUI by @flolep2607 in https://github.com/flolep2607/cctop/pull/5\n\
\n\n**Full Changelog**: https://github.com/flolep2607/cctop/compare/v0.7.1...v0.7.2";
assert_eq!(
bullets(body),
["Fix the login hint for a named account, and add a run skill that drives the TUI"]
);
}
#[test]
fn a_hand_written_release_body_survives_intact() {
let body = "A patch, because every line of it fixes something.\n\n - **Handoff** briefs travel in the argv now\n - Bells reach the tab bar";
assert_eq!(
bullets(body),
[
"A patch, because every line of it fixes something.",
"Handoff briefs travel in the argv now",
"Bells reach the tab bar",
]
);
}
#[test]
fn a_release_with_no_notes_is_still_a_release() {
let empty = Release {
tag_name: "v0.7.3".into(),
assets: Vec::new(),
body: None,
};
assert!(bullets(empty.body.as_deref().unwrap_or_default()).is_empty());
assert_eq!(selected(&[empty], "0.7.2", "0.7.3").len(), 1);
}
#[test]
fn cargo_owns_only_what_sits_directly_in_its_bin() {
let home = tempfile::tempdir().unwrap();
let bin = home.path().join("bin");
std::fs::create_dir(&bin).unwrap();
let exe = bin.join("cctop");
std::fs::write(&exe, b"").unwrap();
assert!(under(&exe, &bin));
let nested = bin.join("vendor");
std::fs::create_dir(&nested).unwrap();
let deep = nested.join("cctop");
std::fs::write(&deep, b"").unwrap();
assert!(!under(&deep, &bin));
let elsewhere = home.path().join("cctop");
std::fs::write(&elsewhere, b"").unwrap();
assert!(!under(&elsewhere, &bin));
}
#[cfg(unix)]
#[test]
fn a_symlinked_cargo_bin_is_still_cargo() {
let home = tempfile::tempdir().unwrap();
let real = home.path().join("real-bin");
std::fs::create_dir(&real).unwrap();
let exe = real.join("cctop");
std::fs::write(&exe, b"").unwrap();
let linked = home.path().join("bin");
std::os::unix::fs::symlink(&real, &linked).unwrap();
assert!(under(&exe, &linked), "the link and its target disagreed");
}
#[test]
fn an_unresolvable_path_is_not_a_cargo_install() {
let home = tempfile::tempdir().unwrap();
let bin = home.path().join("bin");
assert!(!under(&bin.join("cctop"), &bin));
}
#[test]
fn the_cargo_message_names_the_command_that_replaces_it() {
let error = cargo_managed().to_string();
assert!(
error.contains("cargo install cctop --force"),
"got: {error}"
);
assert!(error.contains(current_version()), "got: {error}");
}
#[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 kind = match raw_stage_in(dir.path()) {
Err(error) => error.kind(),
Ok(_) => return,
};
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(kind, std::io::ErrorKind::PermissionDenied);
let error = format!("{:#}", unwritable(dir.path()));
assert!(error.contains("sudo cctop --update"), "got: {error}");
assert!(error.contains("package manager"), "got: {error}");
assert!(
error.contains(&dir.path().display().to_string()),
"got: {error}"
);
}
#[test]
fn sudo_is_only_ever_offered_to_someone_who_can_answer() {
assert_eq!(recourse(false, false, true, true), Recourse::Ask);
assert_eq!(recourse(false, false, false, true), Recourse::Explain);
assert_eq!(recourse(false, false, true, false), Recourse::Explain);
assert_eq!(recourse(false, false, false, false), Recourse::Explain);
assert_eq!(recourse(false, true, true, true), Recourse::Explain);
assert_eq!(recourse(true, true, true, true), Recourse::Privileged);
assert_eq!(recourse(true, false, true, true), Recourse::Privileged);
}
#[test]
fn the_elevated_command_names_the_running_binary_by_path() {
let argv = sudo_argv(Path::new("/usr/local/bin/cctop"));
assert_eq!(argv, ["sudo", "--", "/usr/local/bin/cctop", "--update"]);
}
#[test]
fn a_root_failure_does_not_point_at_sudo() {
let error = format!("{:#}", read_only(Path::new("/usr/local/bin")));
assert!(!error.contains("sudo"), "got: {error}");
assert!(error.contains("/usr/local/bin"), "got: {error}");
assert!(error.contains("read-only"), "got: {error}");
assert!(error.contains("package manager"), "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());
}
}