use crate::config;
use anyhow::{Context, Result, anyhow, bail};
use serde::Deserialize;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const RELEASES_API: &str = "https://api.github.com/repos/Artificial-Humanity/Lucida/releases/latest";
const RELEASES_PAGE: &str = "https://github.com/Artificial-Humanity/Lucida/releases/latest";
const REPO: &str = "https://github.com/Artificial-Humanity/Lucida";
const USER_AGENT: &str = concat!("lucida/", env!("CARGO_PKG_VERSION"));
pub struct Updater {
http: reqwest::blocking::Client,
api: String,
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Check,
Ask,
Yes,
}
fn confirm() -> Result<bool> {
use std::io::{BufRead, Write};
if !std::io::stdin().is_terminal() {
bail!(
"a newer version is available, but there is no terminal to confirm at.\n\n\
Run `lucida update --yes` to install without asking, or \
`lucida update --check` to report only."
);
}
eprint!("A newer version is available. Would you like to update? [y/N] ");
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.lock()
.read_line(&mut answer)
.context("reading your answer")?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[derive(Debug, PartialEq, Eq)]
pub enum Install {
Cargo,
Standalone,
}
impl Updater {
pub fn new() -> Result<Self> {
Self::with_timeout(Duration::from_secs(120))
}
fn with_timeout(timeout: Duration) -> Result<Self> {
Ok(Self {
http: reqwest::blocking::Client::builder()
.timeout(timeout)
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.build()
.context("building HTTP client")?,
api: RELEASES_API.to_string(),
})
}
pub fn run(&self, mode: Mode) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
let release = self.latest()?;
let latest = release.tag_name.trim_start_matches('v');
println!("Current version {current}");
println!("Available version {latest}");
println!();
if !is_newer(latest, current)? {
println!("You have the latest version of Lucida.");
return Ok(());
}
match mode {
Mode::Check => {
println!("A newer version is available. Run `lucida update` to install it.");
return Ok(());
}
Mode::Ask if !confirm()? => {
println!("Not updated.");
return Ok(());
}
_ => {}
}
let exe = std::env::current_exe().context("finding the running binary")?;
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
match install_kind(&exe) {
Install::Cargo => reinstall_with_cargo(&exe, &release.tag_name),
Install::Standalone => self.replace(&exe, &release, latest),
}
}
fn latest(&self) -> Result<Release> {
let response = self
.http
.get(&self.api)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.send()
.with_context(|| format!("asking {} for the latest release", self.api))?;
let status = response.status();
if !status.is_success() {
let hint = if status.as_u16() == 403 {
"\n\nGitHub rate-limits unauthenticated requests by IP; this \
usually clears within the hour. Meanwhile the releases page \
has the binaries."
} else {
""
};
bail!("could not check for updates: HTTP {status}{hint}\n\n{RELEASES_PAGE}");
}
response.json().context("reading the release description")
}
fn replace(&self, exe: &Path, release: &Release, version: &str) -> Result<()> {
let wanted = asset_name(version)?;
let asset = release
.assets
.iter()
.find(|a| a.name == wanted)
.ok_or_else(|| {
let available: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect();
anyhow!(
"release {version} has no asset named `{wanted}` for this platform.\n\n\
It published: {}\n\n\
Download one by hand from {RELEASES_PAGE}",
available.join(", ")
)
})?;
let dir = exe.parent().unwrap_or_else(|| Path::new("."));
writable(dir, exe)?;
println!("Downloading {}…", asset.name);
let bytes = self.download(&asset.browser_download_url)?;
if let Some(sums) = release.assets.iter().find(|a| a.name == format!("{wanted}.sha256")) {
let published = self.download(&sums.browser_download_url)?;
verify(&bytes, &String::from_utf8_lossy(&published))?;
println!("Checksum verified.");
} else {
println!("No published checksum for this asset; skipping verification.");
}
install_over(exe, dir, &bytes)?;
println!("Updated to {version}: {}", exe.display());
Ok(())
}
fn download(&self, url: &str) -> Result<Vec<u8>> {
let response = self
.http
.get(url)
.header("User-Agent", USER_AGENT)
.send()
.with_context(|| format!("downloading {url}"))?;
if !response.status().is_success() {
bail!("downloading {url}: HTTP {}", response.status());
}
Ok(response.bytes().context("reading the download")?.to_vec())
}
}
const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
pub const OPT_OUT: &str = "LUCIDA_NO_UPDATE_CHECK";
pub fn notify_if_due(current: &str) {
if config::var(OPT_OUT).is_some() {
return;
}
if !std::io::stderr().is_terminal() {
return;
}
let Some(stamp) = stamp_path() else { return };
if !is_due(last_checked(&stamp), SystemTime::now()) {
return;
}
record_check(&stamp);
let Ok(updater) = Updater::with_timeout(Duration::from_secs(5)) else {
return;
};
let Ok(release) = updater.latest() else { return };
let latest = release.tag_name.trim_start_matches('v');
if is_newer(latest, current).unwrap_or(false) {
eprintln!(
"note: lucida {latest} is available (this is {current}). \
Run `lucida update`, or set {OPT_OUT}=1 to stop checking."
);
}
}
fn is_due(last: Option<SystemTime>, now: SystemTime) -> bool {
match last {
Some(last) => now.duration_since(last).map_or(true, |d| d >= CHECK_INTERVAL),
None => true,
}
}
fn stamp_path() -> Option<PathBuf> {
let base = if cfg!(target_os = "macos") {
home().map(|h| h.join("Library/Caches"))
} else if cfg!(target_os = "windows") {
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| home().map(|h| h.join(".cache")))
} else {
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".cache")))
};
Some(base?.join("lucida").join("last-update-check"))
}
fn last_checked(path: &Path) -> Option<SystemTime> {
let text = std::fs::read_to_string(path).ok()?;
let secs: u64 = text.trim().parse().ok()?;
Some(UNIX_EPOCH + Duration::from_secs(secs))
}
fn record_check(path: &Path) {
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(path, now.as_secs().to_string());
}
fn cargo_args(tag: &str) -> Vec<String> {
["install", "--git", REPO, "--tag", tag, "--force"]
.iter()
.map(|s| s.to_string())
.collect()
}
fn reinstall_with_cargo(exe: &Path, tag: &str) -> Result<()> {
let args = cargo_args(tag);
let printable = format!("cargo {}", args.join(" "));
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
println!(
"\nThis copy was installed by cargo ({}), so cargo replaces it — which \
means building from source, and that takes a few minutes.\n\n {printable}\n",
exe.display()
);
match std::process::Command::new(&cargo).args(&args).status() {
Ok(status) if status.success() => {
println!("\nUpdated to {tag}.");
Ok(())
}
Ok(status) => bail!(
"`{printable}` exited with {status}, so nothing was replaced — the \
copy you are running is untouched.\n\n\
cargo's own output above says why."
),
Err(e) => bail!(
"could not run cargo ({e}), so this copy cannot be rebuilt here.\n\n\
Run it yourself where cargo is available:\n\n {printable}"
),
}
}
pub fn install_kind(exe: &Path) -> Install {
let cargo_bin = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".cargo")))
.map(|home| home.join("bin"));
match cargo_bin {
Some(bin) if exe.starts_with(&bin) => Install::Cargo,
_ => Install::Standalone,
}
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub fn asset_name(version: &str) -> Result<String> {
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
match (os, arch) {
("macos", _) => Ok(format!("lucida-{version}-macos-universal")),
("linux", "x86_64") => Ok(format!("lucida-{version}-x86_64-linux-musl")),
("windows", "x86_64") => Ok(format!("lucida-{version}-x86_64-windows.exe")),
_ => bail!(
"no release binary is published for {os}/{arch}.\n\n\
Build from source with `cargo build --release`, or see {RELEASES_PAGE}"
),
}
}
fn writable(dir: &Path, exe: &Path) -> Result<()> {
let probe = dir.join(".lucida-update-probe");
match std::fs::write(&probe, b"") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
Ok(())
}
Err(e) => bail!(
"cannot write to {} ({e}), so {} cannot be replaced.\n\n\
Re-run with permission to write there, or download the new binary \
from {RELEASES_PAGE} and put it in place yourself.",
dir.display(),
exe.display()
),
}
}
fn install_over(exe: &Path, dir: &Path, bytes: &[u8]) -> Result<()> {
let staged = dir.join(".lucida-update-staged");
std::fs::write(&staged, bytes).with_context(|| format!("writing {}", staged.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
.context("making the new binary executable")?;
}
#[cfg(windows)]
{
let displaced = dir.join(".lucida-update-old.exe");
let _ = std::fs::remove_file(&displaced);
std::fs::rename(exe, &displaced).with_context(|| {
format!("moving the running binary aside: {}", exe.display())
})?;
if let Err(e) = std::fs::rename(&staged, exe) {
let _ = std::fs::rename(&displaced, exe);
return Err(e).with_context(|| format!("installing over {}", exe.display()));
}
}
#[cfg(not(windows))]
std::fs::rename(&staged, exe)
.with_context(|| format!("installing over {}", exe.display()))?;
Ok(())
}
fn is_newer(candidate: &str, current: &str) -> Result<bool> {
Ok(parts(candidate)? > parts(current)?)
}
fn parts(version: &str) -> Result<(u64, u64, u64)> {
let core = version.trim_start_matches('v');
let core = core.split(['-', '+']).next().unwrap_or(core);
let mut fields = core.split('.').map(str::parse::<u64>);
let mut next = || -> Result<u64> {
fields
.next()
.transpose()
.ok()
.flatten()
.ok_or_else(|| anyhow!("`{version}` is not a version this can compare"))
};
Ok((next()?, next()?, next()?))
}
fn verify(bytes: &[u8], published: &str) -> Result<()> {
use sha2::{Digest, Sha256};
let expected = published
.split_whitespace()
.next()
.ok_or_else(|| anyhow!("the published checksum file was empty"))?
.to_ascii_lowercase();
let actual = format!("{:x}", Sha256::digest(bytes));
if actual != expected {
bail!(
"the download does not match its published checksum, so it was not \
installed.\n\n expected {expected}\n got {actual}\n\n\
Retry, and if it persists take the binary from {RELEASES_PAGE}"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testserver::{Reply, serve};
#[test]
fn versions_compare_numerically_not_as_text() {
assert!(is_newer("0.7.0", "0.6.0").unwrap());
assert!(is_newer("1.0.0", "0.9.9").unwrap());
assert!(!is_newer("0.6.0", "0.6.0").unwrap());
assert!(!is_newer("0.5.9", "0.6.0").unwrap());
assert!(is_newer("0.10.0", "0.9.0").unwrap());
assert!(!is_newer("0.9.0", "0.10.0").unwrap());
}
#[test]
fn a_leading_v_and_a_prerelease_suffix_are_tolerated() {
assert!(is_newer("v0.7.0", "0.6.0").unwrap());
assert!(is_newer("0.7.0-rc1", "0.6.0").unwrap());
assert!(parts("not-a-version").is_err());
}
#[test]
fn the_asset_name_matches_what_the_release_workflow_publishes() {
let name = asset_name("0.6.0").unwrap();
assert!(name.starts_with("lucida-0.6.0-"), "{name}");
match std::env::consts::OS {
"macos" => assert_eq!(name, "lucida-0.6.0-macos-universal"),
"linux" => assert_eq!(name, "lucida-0.6.0-x86_64-linux-musl"),
"windows" => assert_eq!(name, "lucida-0.6.0-x86_64-windows.exe"),
other => panic!("untested platform {other}"),
}
}
#[test]
fn a_check_is_due_once_a_day_and_survives_a_backwards_clock() {
let now = SystemTime::now();
assert!(is_due(None, now), "a machine that has never checked is due");
assert!(is_due(Some(now - CHECK_INTERVAL), now));
assert!(is_due(Some(now - CHECK_INTERVAL * 3), now));
assert!(!is_due(Some(now), now), "twice in a row is not due");
assert!(!is_due(Some(now - Duration::from_secs(60)), now));
assert!(is_due(Some(now + CHECK_INTERVAL), now));
}
#[test]
fn the_stamp_is_a_cache_path_not_a_config_path() {
let Some(path) = stamp_path() else { return };
let text = path.to_string_lossy();
assert!(text.contains("lucida"), "{text}");
assert!(!text.contains("config.env"), "{text}");
let accepted: &[&str] = if cfg!(target_os = "macos") {
&["Caches"]
} else if cfg!(target_os = "windows") {
&["Local", "cache"]
} else {
&["cache"]
};
assert!(
accepted.iter().any(|marker| text.contains(marker)),
"not under this platform's cache location (wanted one of {accepted:?}): {text}"
);
}
#[test]
fn the_cargo_reinstall_is_pinned_to_the_release_tag() {
let args = cargo_args("v0.7.0");
assert_eq!(
args,
vec!["install", "--git", REPO, "--tag", "v0.7.0", "--force"]
);
assert!(args.contains(&"--tag".to_string()));
assert!(args.contains(&"--force".to_string()));
}
#[test]
fn a_cargo_installed_binary_is_recognised() {
let home = PathBuf::from("/tmp/cargo-home-fixture");
unsafe { std::env::set_var("CARGO_HOME", &home) };
assert_eq!(install_kind(&home.join("bin/lucida")), Install::Cargo);
assert_eq!(
install_kind(Path::new("/usr/local/bin/lucida")),
Install::Standalone
);
unsafe { std::env::remove_var("CARGO_HOME") };
}
#[test]
fn a_checksum_mismatch_refuses_the_download() {
let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(verify(b"", &format!("{empty} lucida")).is_ok());
assert!(verify(b"", &format!("{empty} lucida").to_uppercase()).is_ok());
let wrong = verify(b"different bytes", &format!("{empty} lucida"));
let message = wrong.unwrap_err().to_string();
assert!(message.contains("does not match"), "{message}");
assert!(message.contains("was not installed"), "{message}");
}
#[test]
fn checking_reports_a_newer_release_without_installing() {
let body = r#"{"tag_name":"v99.0.0","assets":[
{"name":"lucida-99.0.0-macos-universal",
"browser_download_url":"{{server}}/download"}]}"#;
let server = serve(vec![Reply::json(body)]);
let updater = Updater {
http: reqwest::blocking::Client::new(),
api: format!("{}/releases/latest", server.url()),
};
updater.run(Mode::Check).unwrap();
let requests = server.finish();
assert_eq!(requests.len(), 1, "a check must not download anything");
assert_eq!(requests[0].header("user-agent"), Some(USER_AGENT));
assert_eq!(requests[0].header("accept"), Some("application/vnd.github+json"));
}
#[test]
fn asking_with_no_terminal_refuses_rather_than_installing() {
let body = r#"{"tag_name":"v99.0.0","assets":[
{"name":"lucida-99.0.0-macos-universal",
"browser_download_url":"{{server}}/download"}]}"#;
let server = serve(vec![Reply::json(body)]);
let updater = Updater {
http: reqwest::blocking::Client::new(),
api: format!("{}/releases/latest", server.url()),
};
let message = updater.run(Mode::Ask).unwrap_err().to_string();
assert!(message.contains("no terminal to confirm at"), "{message}");
assert!(message.contains("--yes"), "{message}");
let requests = server.finish();
assert_eq!(
requests.len(),
1,
"it must refuse before downloading anything"
);
}
#[test]
fn an_unavailable_release_api_names_the_releases_page() {
let server = serve(vec![Reply::status(403, r#"{"message":"rate limit"}"#)]);
let updater = Updater {
http: reqwest::blocking::Client::new(),
api: format!("{}/releases/latest", server.url()),
};
let message = updater.run(Mode::Check).unwrap_err().to_string();
assert!(message.contains("rate-limit"), "{message}");
assert!(message.contains(RELEASES_PAGE), "{message}");
server.finish();
}
}