use anyhow::{Context, Result, bail};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use ureq::ResponseExt;
use crate::{embed, home};
const REPO: &str = "semlith/semlith";
fn origin() -> String {
#[cfg(debug_assertions)]
if let Ok(from_env) = std::env::var("SEMLITH_RELEASES_ORIGIN") {
return from_env;
}
"https://github.com".to_string()
}
fn overridden() -> bool {
#[cfg(debug_assertions)]
{
std::env::var_os("SEMLITH_RELEASES_ORIGIN").is_some()
}
#[cfg(not(debug_assertions))]
false
}
const MAX_ARCHIVE: u64 = 256 * 1024 * 1024;
const MAX_SUMS: u64 = 64 * 1024;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
fn valid_tag(tag: &str) -> bool {
let Some(rest) = tag.strip_prefix('v') else {
return false;
};
let (version, pre) = match rest.find(['-', '+']) {
Some(at) => (&rest[..at], Some(&rest[at + 1..])),
None => (rest, None),
};
let numbers: Vec<&str> = version.split('.').collect();
if numbers.len() != 3
|| !numbers
.iter()
.all(|n| !n.is_empty() && n.len() <= 9 && n.bytes().all(|b| b.is_ascii_digit()))
{
return false;
}
match pre {
None => true,
Some(pre) => {
!pre.is_empty()
&& pre
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
}
}
}
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> {
if let Some(reason) = offline() {
anyhow::bail!("{reason}");
}
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 !valid_tag(&tag) {
bail!(
"{tag} is not a version tag — semlith installs vMAJOR.MINOR.PATCH, \
optionally with a pre-release suffix"
);
}
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}"), MAX_ARCHIVE, true)?;
let sums = String::from_utf8(get(&format!("{base}/SHA256SUMS"), MAX_SUMS, 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 = crate::embed::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();
}
let dir = current.parent().unwrap_or(work);
for entry in std::fs::read_dir(work).into_iter().flatten().flatten() {
let path = entry.path();
if !path.is_file() || !is_library(&path) {
continue;
}
let target = dir.join(entry.file_name());
std::fs::rename(&path, &target)
.or_else(|_| std::fs::copy(&path, &target).map(|_| ()))
.with_context(|| format!("installing {}", target.display()))?;
}
println!("installed {} at {}", &tag[1..], current.display());
let status = crate::service::status();
let names_this = status
.definition
.as_deref()
.and_then(|path| std::fs::read_to_string(path).ok())
.is_some_and(|text| text.contains(&*current.to_string_lossy()));
if status.installed && names_this {
match std::process::Command::new(current)
.args(["start", "--service"])
.output()
{
Ok(out) if out.status.success() => {
println!("re-registered the login service with the new binary")
}
Ok(out) => println!(
"the login service could not be re-registered ({}) — run `semlith start --service`",
String::from_utf8_lossy(&out.stderr).trim()
),
Err(e) => println!(
"the login service could not be re-registered ({e}) — run `semlith start --service`"
),
}
}
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.is_dir() {
continue;
}
let Some(file_name) = strip_leading_dir(entry.name()).map(str::to_string) else {
continue;
};
let out = into.join(&file_name);
let mut bytes = Vec::new();
entry
.read_to_end(&mut bytes)
.with_context(|| format!("unpacking {file_name}"))?;
std::fs::write(&out, bytes).with_context(|| format!("writing {}", out.display()))?;
}
} else {
let file = std::fs::File::open(archive)
.with_context(|| format!("opening {}", archive.display()))?;
untar(flate2::read::GzDecoder::new(file), into)?;
}
let out = into.join(name);
if !out.exists() {
bail!("the release archive has no {name}");
}
Ok(out)
}
fn is_library(path: &Path) -> bool {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
name.ends_with(".dylib") || name.ends_with(".dll") || name.contains(".so")
}
fn strip_leading_dir(path: &str) -> Option<&str> {
if path.contains('\\') || path.starts_with('/') {
return None;
}
let (_, rest) = path.split_once('/')?;
if rest.is_empty() || rest.contains('/') || rest == ".." || rest == "." {
return None;
}
Some(rest)
}
fn untar(mut reader: impl Read, into: &Path) -> Result<()> {
let mut header = [0u8; 512];
loop {
if !read_exact_or_end(&mut reader, &mut header)? {
return Ok(());
}
if header.iter().all(|b| *b == 0) {
return Ok(());
}
let name = field(&header[0..100]);
let size = octal(&header[124..136]).context("a tar header has no readable size")?;
let kind = header[156];
let wanted = matches!(kind, b'0' | 0)
.then(|| strip_leading_dir(&name))
.flatten()
.map(|n| into.join(n));
let mut left = size;
match wanted {
Some(path) => {
let mut out = std::fs::File::create(&path)
.with_context(|| format!("writing {}", path.display()))?;
let mut buffer = [0u8; 64 * 1024];
while left > 0 {
let want = (left as usize).min(buffer.len());
reader
.read_exact(&mut buffer[..want])
.context("the release archive ended mid-file")?;
use std::io::Write;
out.write_all(&buffer[..want])
.with_context(|| format!("writing {}", path.display()))?;
left -= want as u64;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = octal(&header[100..108]).unwrap_or(0o644) as u32;
let _ = std::fs::set_permissions(
&path,
std::fs::Permissions::from_mode(mode & 0o755),
);
}
}
None => skip(&mut reader, left)?,
}
let padding = (512 - (size % 512)) % 512;
skip(&mut reader, padding)?;
}
}
fn read_exact_or_end(reader: &mut impl Read, buffer: &mut [u8]) -> Result<bool> {
let mut filled = 0;
while filled < buffer.len() {
match reader.read(&mut buffer[filled..])? {
0 if filled == 0 => return Ok(false),
0 => bail!("the release archive ended mid-header"),
n => filled += n,
}
}
Ok(true)
}
fn skip(reader: &mut impl Read, mut bytes: u64) -> Result<()> {
let mut sink = [0u8; 64 * 1024];
while bytes > 0 {
let want = (bytes as usize).min(sink.len());
reader
.read_exact(&mut sink[..want])
.context("the release archive ended mid-file")?;
bytes -= want as u64;
}
Ok(())
}
fn field(bytes: &[u8]) -> String {
let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
fn octal(bytes: &[u8]) -> Option<u64> {
let text = field(bytes);
let digits = text.trim().trim_end_matches(' ');
if digits.is_empty() {
return Some(0);
}
u64::from_str_radix(digits, 8).ok()
}
pub fn offline() -> Option<&'static str> {
embed::airgap().then_some(
"airgap is on (SEMLITH_AIRGAP, --airgap or the Privacy page), 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 !writable(¤t) {
return Some("the semlith binary is not writable by this user");
}
None
}
#[cfg(unix)]
fn writable(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
return false;
};
let Some(parent) = path.parent() else {
return false;
};
let Ok(c_parent) = std::ffi::CString::new(parent.as_os_str().as_bytes()) else {
return false;
};
unsafe {
libc::access(c_path.as_ptr(), libc::W_OK) == 0
&& libc::access(c_parent.as_ptr(), libc::W_OK) == 0
}
}
#[cfg(not(unix))]
fn writable(path: &Path) -> bool {
!std::fs::metadata(path)
.map(|m| m.permissions().readonly())
.unwrap_or(true)
}
fn owned(current: &Path) -> bool {
let Some(parent) = current.parent() else {
return false;
};
let real = |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
home::bin_dir().is_ok_and(|bin| real(parent) == real(&bin))
}
fn latest_tag() -> Result<String> {
let url = format!("{}/{REPO}/releases/latest", origin());
crate::add::note_outbound("upgrade", &url);
let response = agent()
.get(&url)
.call()
.with_context(|| format!("asking {url} which release is newest"))?;
let landed = response.get_uri().to_string();
let tag = landed
.rsplit('/')
.next()
.with_context(|| format!("{url} redirected to {landed}, which names no tag"))?;
if !valid_tag(tag) {
bail!("{url} redirected to {landed}, which does not name a version tag");
}
Ok(tag.to_string())
}
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.https_only(!overridden())
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_global(Some(DOWNLOAD_TIMEOUT))
.build()
.into()
}
fn get(url: &str, cap: u64, 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>> {
crate::add::note_outbound("upgrade", url);
let mut response = agent()
.get(url)
.call()
.with_context(|| format!("fetching {url}"))?;
let mut bytes = Vec::new();
response
.body_mut()
.as_reader()
.take(cap + 1)
.read_to_end(&mut bytes)
.with_context(|| format!("reading {url}"))?;
if bytes.len() as u64 > cap {
bail!(
"{url} is larger than the {} MiB semlith will download for a release; \
nothing was written",
cap / (1024 * 1024)
);
}
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::*;
#[test]
fn only_a_version_tag_is_a_tag() {
assert!(valid_tag("v0.14.0"));
assert!(valid_tag("v1.0.0"));
assert!(valid_tag("v99.0.0"));
assert!(valid_tag("v0.14.0-rc.1"));
assert!(valid_tag("v0.14.0+build.5"));
assert!(!valid_tag("v9.9.9-/../x"));
assert!(!valid_tag("v9.9.9/../../etc"));
assert!(!valid_tag("../../etc/passwd"));
assert!(!valid_tag("v1.0.0?x=1"));
assert!(!valid_tag("v1.0.0#x"));
assert!(!valid_tag("https://elsewhere/v1.0.0"));
assert!(!valid_tag("v1.0"));
assert!(!valid_tag("v1.0.0.0"));
assert!(!valid_tag("vx.y.z"));
assert!(!valid_tag("1.0.0"));
assert!(!valid_tag(""));
assert!(!valid_tag("v"));
assert!(!valid_tag("v1.0.0 "));
assert!(!valid_tag("v1.0.0-"));
assert!(!valid_tag("v1.0.9999999999"));
}
#[test]
fn an_archive_entry_names_one_file_in_one_directory() {
assert_eq!(
strip_leading_dir("semlith-v0.14.0-x86_64-unknown-linux-gnu/semlith"),
Some("semlith")
);
assert_eq!(
strip_leading_dir("semlith-v0.14.0-x86_64-unknown-linux-gnu/libonnxruntime.so"),
Some("libonnxruntime.so")
);
assert_eq!(strip_leading_dir("dir/../../etc/passwd"), None);
assert_eq!(strip_leading_dir("dir/nested/file"), None);
assert_eq!(strip_leading_dir("/etc/passwd"), None);
assert_eq!(strip_leading_dir("dir/.."), None);
assert_eq!(strip_leading_dir("bare-file"), None);
assert_eq!(strip_leading_dir("dir\\windows\\path"), None);
assert_eq!(strip_leading_dir("dir/"), None);
}
#[test]
fn only_a_library_is_installed_beside_the_binary() {
assert!(is_library(Path::new("libonnxruntime.so")));
assert!(is_library(Path::new("libonnxruntime.so.1.24.4")));
assert!(is_library(Path::new("libonnxruntime.dylib")));
assert!(is_library(Path::new("onnxruntime.dll")));
assert!(!is_library(Path::new("semlith")));
assert!(!is_library(Path::new("README.md")));
assert!(!is_library(Path::new("LICENSE")));
assert!(!is_library(Path::new("CHANGELOG.md")));
}
#[test]
#[cfg(not(debug_assertions))]
fn a_release_build_has_one_origin_and_no_way_to_be_told_another() {
unsafe { std::env::set_var("SEMLITH_RELEASES_ORIGIN", "http://elsewhere.example") };
assert_eq!(origin(), "https://github.com");
assert!(!overridden());
unsafe { std::env::remove_var("SEMLITH_RELEASES_ORIGIN") };
}
#[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"));
}
}