use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::{Path, PathBuf};
use ureq::ResponseExt;
use crate::{embed, home};
const REPO: &str = "semlith/semlith";
fn origin() -> String {
std::env::var("SEMLITH_RELEASES_ORIGIN").unwrap_or_else(|_| "https://github.com".to_string())
}
pub const UPGRADE_AVAILABLE: i32 = 10;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Check {
pub installed: String,
pub latest: String,
pub available: bool,
pub blocked: Option<String>,
}
pub fn check() -> Result<Check> {
let installed = env!("CARGO_PKG_VERSION").to_string();
let latest = latest_tag()?;
let available = newer(&latest, &installed);
Ok(Check {
available,
blocked: blocked().map(|e| e.to_string()),
installed,
latest,
})
}
pub fn apply(tag: Option<String>) -> Result<()> {
if let Some(reason) = blocked() {
bail!("{reason}");
}
let installed = env!("CARGO_PKG_VERSION");
let tag = match tag {
Some(t) if t.starts_with('v') => t,
Some(t) => format!("v{t}"),
None => latest_tag()?,
};
if !newer(&tag, installed) {
println!("already on {installed}; {} is not newer", &tag[1..]);
return Ok(());
}
let target = host_target()?;
let archive = format!("semlith-{tag}-{target}.{}", archive_ext());
let base = format!("{}/{REPO}/releases/download/{tag}", origin());
println!("upgrading {installed} -> {} for {target}", &tag[1..]);
let current = std::env::current_exe().context("locating the running binary")?;
let dir = current
.parent()
.context("the running binary has no directory")?
.to_path_buf();
let work = dir.join(format!(".semlith-upgrade-{}", std::process::id()));
std::fs::create_dir_all(&work).with_context(|| format!("creating {}", work.display()))?;
let result = swap(&base, &archive, &work, ¤t, &tag);
std::fs::remove_dir_all(&work).ok();
result
}
fn swap(base: &str, archive: &str, work: &Path, current: &Path, tag: &str) -> Result<()> {
let bytes = get(&format!("{base}/{archive}"), true)?;
let sums = String::from_utf8(get(&format!("{base}/SHA256SUMS"), false)?)
.context("SHA256SUMS was not text")?;
let expected = sums
.lines()
.find_map(|line| {
let (hash, name) = line.split_once(" ")?;
(name.trim() == archive).then(|| hash.trim().to_ascii_lowercase())
})
.with_context(|| format!("{archive} has no line in the release's SHA256SUMS"))?;
let actual = format!("{:x}", Sha256::digest(&bytes));
if actual != expected {
bail!(
"checksum mismatch for {archive}\n expected {expected}\n got {actual}\n\
nothing was replaced"
);
}
let downloaded = work.join(archive);
std::fs::write(&downloaded, &bytes)
.with_context(|| format!("writing {}", downloaded.display()))?;
let fresh = unpack(&downloaded, work)?;
let old = current.with_extension("old");
std::fs::remove_file(&old).ok();
std::fs::rename(current, &old)
.with_context(|| format!("moving {} aside", current.display()))?;
if let Err(e) = std::fs::rename(&fresh, current) {
std::fs::rename(&old, current).ok();
return Err(e).with_context(|| format!("installing {}", current.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(current, std::fs::Permissions::from_mode(0o755)).ok();
}
println!("installed {} at {}", &tag[1..], current.display());
println!(
"the previous binary is {} — delete it when you are happy",
old.display()
);
Ok(())
}
fn unpack(archive: &Path, into: &Path) -> Result<PathBuf> {
let name = if cfg!(windows) {
"semlith.exe"
} else {
"semlith"
};
if cfg!(windows) {
let file = std::fs::File::open(archive)
.with_context(|| format!("opening {}", archive.display()))?;
let mut zip = zip::ZipArchive::new(file).context("reading the release zip")?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).context("reading a zip entry")?;
if !entry.name().ends_with(name) {
continue;
}
let out = into.join(name);
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).context("unpacking semlith")?;
std::fs::write(&out, bytes).with_context(|| format!("writing {}", out.display()))?;
return Ok(out);
}
bail!("the release zip has no {name}");
}
let status = std::process::Command::new("tar")
.arg("xzf")
.arg(archive)
.arg("-C")
.arg(into)
.arg("--strip-components=1")
.status()
.context("running tar")?;
if !status.success() {
bail!("tar could not unpack {}", archive.display());
}
let out = into.join(name);
if !out.exists() {
bail!("the release archive has no {name}");
}
Ok(out)
}
pub fn offline() -> Option<&'static str> {
embed::airgap().then_some(
"SEMLITH_AIRGAP is set, so this run will not reach the network. \
Upgrade on a connected machine and copy the binary across.",
)
}
fn blocked() -> Option<&'static str> {
if let Some(reason) = offline() {
return Some(reason);
}
let Ok(current) = std::env::current_exe() else {
return Some("the running binary could not be located");
};
if !owned(¤t) {
return Some(
"this semlith was not installed by install.sh or install.ps1. \
Upgrade it the way you installed it — `cargo install semlith` for \
a cargo install, or your package manager.",
);
}
if std::fs::metadata(¤t)
.map(|m| m.permissions().readonly())
.unwrap_or(true)
{
return Some("the semlith binary is not writable by this user");
}
None
}
fn owned(current: &Path) -> bool {
current.parent() == Some(home::bin_dir().as_path())
}
fn latest_tag() -> Result<String> {
let url = format!("{}/{REPO}/releases/latest", origin());
let response = ureq::get(&url)
.call()
.with_context(|| format!("asking {url} which release is newest"))?;
let landed = response.get_uri().to_string();
let tag = landed
.rsplit('/')
.next()
.filter(|t| t.starts_with('v'))
.with_context(|| format!("{url} redirected to {landed}, which names no tag"))?;
Ok(tag.to_string())
}
fn get(url: &str, show_progress: bool) -> Result<Vec<u8>> {
let bar = show_progress.then(|| {
let bar = cliclack::spinner();
bar.start(format!("downloading {url}"));
bar
});
let fetched = (|| -> Result<Vec<u8>> {
let mut response = ureq::get(url)
.call()
.with_context(|| format!("fetching {url}"))?;
let mut bytes = Vec::new();
response
.body_mut()
.as_reader()
.read_to_end(&mut bytes)
.with_context(|| format!("reading {url}"))?;
Ok(bytes)
})();
match (&bar, &fetched) {
(Some(bar), Ok(bytes)) => bar.stop(format!("downloaded {} KiB", bytes.len() / 1024)),
(Some(bar), Err(_)) => bar.error("download failed"),
_ => {}
}
fetched
}
fn host_target() -> Result<&'static str> {
Ok(match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
("macos", "aarch64") => "aarch64-apple-darwin",
("windows", "x86_64") => "x86_64-pc-windows-msvc",
(os, arch) => bail!(
"there is no semlith release for {os}/{arch} — see \
https://github.com/{REPO}/releases"
),
})
}
fn newer(latest: &str, installed: &str) -> bool {
match (semver(latest), semver(installed)) {
(Some(l), Some(i)) => l > i,
_ => false,
}
}
fn semver(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v.trim_start_matches('v').split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.split(['-', '+']).next()?.parse().ok()?;
Some((major, minor, patch))
}
fn archive_ext() -> &'static str {
if cfg!(windows) { "zip" } else { "tar.gz" }
}
#[cfg(test)]
mod tests {
use super::newer;
#[test]
fn only_a_higher_version_counts_as_an_upgrade() {
assert!(newer("v0.11.0", "0.10.0"));
assert!(newer("v0.10.1", "0.10.0"));
assert!(newer("v1.0.0", "0.99.99"));
assert!(!newer("v0.10.0", "0.10.0"));
assert!(!newer("v0.9.0", "0.10.0"));
assert!(!newer("not-a-version", "0.10.0"));
}
}