use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
pub const RELEASE_REPO: &str = "leidarljos/ljos";
pub const BINARIES: &[&str] = &["ljos", "ljos-mcp", "ljos-hud"];
#[must_use]
pub fn target() -> Option<&'static str> {
match (std::env::consts::ARCH, std::env::consts::OS) {
("x86_64", "linux") => Some("x86_64-unknown-linux-gnu"),
("aarch64", "macos") => Some("aarch64-apple-darwin"),
_ => None,
}
}
#[must_use]
pub fn asset_name(version: &str, target: &str) -> String {
format!("ljos-v{}-{target}.tar.gz", version.trim_start_matches('v'))
}
#[must_use]
pub fn digest_of(line: &str) -> Option<String> {
let d = line.split_whitespace().next()?.to_ascii_lowercase();
(d.len() == 64 && d.chars().all(|c| c.is_ascii_hexdigit())).then_some(d)
}
#[must_use]
pub fn names_version(output: &str, binary: &str, version: &str) -> bool {
let want = format!("{binary} {}", version.trim_start_matches('v'));
output.lines().next().is_some_and(|l| l.trim() == want)
}
fn fetch(url: &str, to: &Path) -> Result<()> {
let resp = ureq::get(url)
.timeout(std::time::Duration::from_secs(120))
.call()
.with_context(|| format!("upgrade: GET {url}"))?;
let mut body = Vec::new();
resp.into_reader()
.take(256 * 1024 * 1024)
.read_to_end(&mut body)
.with_context(|| format!("upgrade: reading {url}"))?;
std::fs::write(to, body)?;
Ok(())
}
fn latest() -> Result<String> {
let url = format!("https://api.github.com/repos/{RELEASE_REPO}/releases/latest");
let v: serde_json::Value = ureq::get(&url)
.set("Accept", "application/vnd.github+json")
.timeout(std::time::Duration::from_secs(30))
.call()
.context("upgrade: asking for the latest release")?
.into_json()?;
v["tag_name"]
.as_str()
.map(|t| t.trim_start_matches('v').to_string())
.context("upgrade: the latest release has no tag")
}
fn run_ok(cmd: &str, args: &[&str]) -> Result<String> {
let out = std::process::Command::new(cmd)
.args(args)
.stdin(std::process::Stdio::null())
.output()
.with_context(|| format!("upgrade: running {cmd}"))?;
if !out.status.success() {
bail!(
"upgrade: {cmd} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn upgrade(version: Option<&str>, dir: Option<&Path>) -> Result<String> {
let version = match version {
Some(v) => v.trim_start_matches('v').to_string(),
None => latest()?,
};
let target = target().context("upgrade: no published archive for this machine")?;
let dir: PathBuf = match dir {
Some(d) => d.to_path_buf(),
None => std::env::current_exe()?
.canonicalize()?
.parent()
.context("upgrade: the running ljos has no directory")?
.to_path_buf(),
};
let asset = asset_name(&version, target);
let base = format!("https://github.com/{RELEASE_REPO}/releases/download/v{version}");
let work = std::env::var_os("XDG_CACHE_HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(std::env::temp_dir)
.join("ljos")
.join(format!("upgrade-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
std::fs::create_dir_all(&work)?;
let archive = work.join(&asset);
let sum = work.join(format!("{asset}.sha256"));
fetch(&format!("{base}/{asset}"), &archive)?;
fetch(&format!("{base}/{asset}.sha256"), &sum)?;
let want = digest_of(&std::fs::read_to_string(&sum)?)
.context("upgrade: the published checksum is not a sha256 line")?;
let have = digest_of(&run_ok("sha256sum", &[&archive.display().to_string()])?)
.context("upgrade: sha256sum gave no digest")?;
if want != have {
bail!(
"upgrade: {asset} has sha256 {have}, the release publishes {want}; nothing installed"
);
}
run_ok(
"tar",
&[
"-xzf",
&archive.display().to_string(),
"-C",
&work.display().to_string(),
],
)?;
let unpacked = work.join(asset.trim_end_matches(".tar.gz"));
let mut ready = Vec::new();
for name in BINARIES {
let new = unpacked.join(name);
let installed = dir.join(name);
if !new.is_file() {
if *name == "ljos-hud" {
continue;
}
bail!("upgrade: {asset} carries no {name}; nothing installed");
}
if *name == "ljos-hud" && !installed.exists() {
continue;
}
let bytes = std::fs::read(&new)?;
if !bytes.starts_with(b"\x7fELF") && !bytes.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) {
bail!("upgrade: {name} in {asset} is not an executable; nothing installed");
}
let said = run_ok(&new.display().to_string(), &["--version"])?;
if !names_version(&said, name, &version) {
bail!(
"upgrade: {name} in {asset} says {:?}, not {name} {version}; nothing installed",
said.lines().next().unwrap_or("")
);
}
ready.push((name, new, installed));
}
let mut out = String::new();
for (name, new, installed) in ready {
if installed.exists() {
let keep = dir.join(format!("{name}.bak-pre-{version}"));
std::fs::copy(&installed, &keep)?;
}
let staged = dir.join(format!("{name}.new"));
std::fs::copy(&new, &staged)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
}
std::fs::rename(&staged, &installed)?;
out.push_str(&format!(
"installed {name} {version} at {}\n",
installed.display()
));
}
let _ = std::fs::remove_dir_all(&work);
out.push_str(&format!(
"from {base}/{asset}, sha256 {have}; the old binaries are kept as NAME.bak-pre-{version}\n"
));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_archive_name_and_its_checks_are_the_release_workflow_s() {
assert_eq!(
asset_name("0.22.3", "x86_64-unknown-linux-gnu"),
"ljos-v0.22.3-x86_64-unknown-linux-gnu.tar.gz"
);
assert_eq!(
asset_name("v0.22.3", "aarch64-apple-darwin"),
"ljos-v0.22.3-aarch64-apple-darwin.tar.gz"
);
let d = "d6e305288c898a34ec65e8f7c067bb680e77f49eeb041692be2e6be2e68d0f85";
assert_eq!(
digest_of(&format!("{d} ljos-v0.22.3.tar.gz\n")).as_deref(),
Some(d)
);
assert_eq!(digest_of("not a digest"), None);
assert_eq!(digest_of(&d[..63]), None, "a short digest is no digest");
assert!(names_version("ljos 0.22.3\n", "ljos", "0.22.3"));
assert!(names_version("ljos-mcp 0.22.3", "ljos-mcp", "v0.22.3"));
assert!(!names_version("ljos 0.22.2", "ljos", "0.22.3"));
assert!(
!names_version("#!/usr/bin/env python3", "ljos", "0.22.3"),
"a script is not the release"
);
}
}