use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::OdlError;
const RELEASE_API: &str = "https://api.github.com/repos/jd1378/odl/releases/latest";
pub const BUILD_TARGET: &str = env!("ODL_BUILD_TARGET");
const MAX_CHECKSUM_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallReceipt {
pub install_dir: PathBuf,
#[serde(default)]
pub tag: Option<String>,
#[serde(default)]
pub installer: Option<String>,
}
impl InstallReceipt {
pub fn path() -> PathBuf {
crate::fs_utils::get_odl_dir().join("install-receipt.toml")
}
pub async fn load() -> Option<Self> {
let raw = tokio::fs::read_to_string(Self::path()).await.ok()?;
toml::from_str(&raw).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ineligible {
ManagedBy(&'static str),
NotWritable(PathBuf),
UnknownInstall(PathBuf),
}
impl Ineligible {
pub fn explain(&self) -> String {
match self {
Ineligible::ManagedBy(manager) => format!(
"this odl was installed by {manager}, which tracks its own copy — update it there \
rather than writing over the file it installed"
),
Ineligible::NotWritable(path) => format!(
"{} is not writable by this user; re-run with the privileges that installed it, \
or re-install with the script into a directory you own",
path.display()
),
Ineligible::UnknownInstall(path) => format!(
"{} was not installed by odl's install script, so odl will not replace it. \
Install with the script to enable updates: \
https://github.com/jd1378/odl#installation",
path.display()
),
}
}
}
fn managed_by(path: &Path) -> Option<&'static str> {
let text = path.to_string_lossy().replace('\\', "/");
if text.starts_with("/nix/store/") {
return Some("Nix");
}
if text.contains("/homebrew/") || text.contains("/Cellar/") {
return Some("Homebrew");
}
if text.contains("/.cargo/bin/") {
return Some("cargo install");
}
if text.starts_with("/usr/bin/") || text.starts_with("/usr/sbin/") || text.starts_with("/bin/")
{
return Some("a system package");
}
None
}
fn default_install_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if cfg!(windows) {
if let Some(local) = dirs::data_local_dir() {
dirs.push(local.join("Programs").join("odl"));
}
} else if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".local").join("bin"));
}
dirs
}
async fn is_replaceable(exe: &Path) -> bool {
let Some(dir) = exe.parent() else {
return false;
};
let probe = dir.join(format!(".odl-update-probe-{}", std::process::id()));
match tokio::fs::write(&probe, b"").await {
Ok(()) => {
let _ = tokio::fs::remove_file(&probe).await;
true
}
Err(_) => false,
}
}
pub async fn eligibility(exe: &Path) -> Result<(), Ineligible> {
let receipt = InstallReceipt::load().await;
let claimed = decide(exe, receipt.as_ref(), &default_install_dirs())?;
if !is_replaceable(claimed).await {
return Err(Ineligible::NotWritable(exe.to_path_buf()));
}
Ok(())
}
fn decide<'a>(
exe: &'a Path,
receipt: Option<&InstallReceipt>,
default_dirs: &[PathBuf],
) -> Result<&'a Path, Ineligible> {
if let Some(manager) = managed_by(exe) {
return Err(Ineligible::ManagedBy(manager));
}
let dir = exe.parent();
let receipt_matches = receipt.is_some_and(|r| dir == Some(r.install_dir.as_path()));
let in_default_dir = dir.is_some_and(|d| default_dirs.iter().any(|known| known == d));
if receipt_matches || in_default_dir {
Ok(exe)
} else {
Err(Ineligible::UnknownInstall(exe.to_path_buf()))
}
}
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
assets: Vec<ReleaseAsset>,
}
#[derive(Debug, Deserialize)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
#[derive(Debug, Clone)]
pub struct UpdatePlan {
pub tag: String,
pub version: String,
pub name: String,
pub url: String,
pub sha256: String,
pub size: u64,
}
fn other(message: impl Into<String>) -> OdlError {
OdlError::Other {
message: message.into(),
origin: Box::new(std::io::Error::other("self-update")),
}
}
fn asset_extension() -> &'static str {
if cfg!(windows) { "zip" } else { "tar.gz" }
}
pub fn is_newer(candidate: &str, current: &str) -> bool {
fn parts(v: &str) -> Option<(u64, u64, u64)> {
let core = v.trim().trim_start_matches('v');
let core = core.split(['-', '+']).next().unwrap_or(core);
let mut it = core.split('.');
let major = it.next()?.parse().ok()?;
let minor = it.next().unwrap_or("0").parse().ok()?;
let patch = it.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
match (parts(candidate), parts(current)) {
(Some(new), Some(now)) => new > now,
_ => false,
}
}
fn digest_from_checksum_file(body: &str, asset: &str) -> Option<String> {
body.lines().find_map(|line| {
let mut fields = line.split_whitespace();
let digest = fields.next()?;
match fields.next() {
Some(name) if name.trim_start_matches('*') != asset => None,
_ => {
let digest = digest.trim().to_ascii_lowercase();
(digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit()))
.then_some(digest)
}
}
})
}
pub async fn plan(client: &reqwest::Client, current: &str) -> Result<Option<UpdatePlan>, OdlError> {
plan_from(client, RELEASE_API, BUILD_TARGET, current).await
}
async fn plan_from(
client: &reqwest::Client,
api: &str,
target: &str,
current: &str,
) -> Result<Option<UpdatePlan>, OdlError> {
let release = client
.get(api)
.header(reqwest::header::USER_AGENT, "odl")
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()
.await
.map_err(|e| other(format!("could not reach the release listing: {e}")))?
.error_for_status()
.map_err(|e| other(format!("the release listing was refused: {e}")))?
.bytes()
.await
.map_err(|e| other(format!("the release listing could not be read: {e}")))?;
let release: Release = serde_json::from_slice(&release)
.map_err(|e| other(format!("the release listing could not be parsed: {e}")))?;
if !is_newer(&release.tag_name, current) {
return Ok(None);
}
let wanted = format!("odl-{}-{}.{}", release.tag_name, target, asset_extension());
let asset = release
.assets
.iter()
.find(|a| a.name == wanted)
.ok_or_else(|| {
other(format!(
"release {} publishes no build for {target}; install the one you want from \
https://github.com/jd1378/odl/releases",
release.tag_name
))
})?;
let sums = release
.assets
.iter()
.find(|a| a.name == format!("{wanted}.sha256"))
.ok_or_else(|| {
other(format!(
"release {} publishes no checksum for {wanted}, so the download cannot be \
verified; update by hand if you trust it",
release.tag_name
))
})?;
if sums.size as usize > MAX_CHECKSUM_BYTES {
return Err(other("the published checksum file is implausibly large"));
}
let body = client
.get(&sums.browser_download_url)
.header(reqwest::header::USER_AGENT, "odl")
.send()
.await
.map_err(|e| other(format!("could not fetch the checksum: {e}")))?
.error_for_status()
.map_err(|e| other(format!("the checksum was refused: {e}")))?
.text()
.await
.map_err(|e| other(format!("the checksum could not be read: {e}")))?;
if body.len() > MAX_CHECKSUM_BYTES {
return Err(other("the published checksum file is implausibly large"));
}
let sha256 = digest_from_checksum_file(&body, &asset.name).ok_or_else(|| {
other(format!(
"no usable SHA-256 for {} was published",
asset.name
))
})?;
Ok(Some(UpdatePlan {
version: release.tag_name.trim_start_matches('v').to_string(),
tag: release.tag_name,
name: asset.name.clone(),
url: asset.browser_download_url.clone(),
sha256,
size: asset.size,
}))
}
pub fn staging_dir() -> PathBuf {
std::env::temp_dir().join("odl-update")
}
pub async fn finish(archive: &Path, exe: &Path) -> Result<PathBuf, OdlError> {
let staging = tempfile::tempdir()?;
let status = tokio::process::Command::new("tar")
.arg("-xf")
.arg(archive)
.current_dir(staging.path())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.status()
.await
.map_err(|e| other(format!("could not run `tar` to unpack the release ({e})")))?;
if !status.success() {
return Err(other("`tar` could not unpack the release archive"));
}
let binary_name = if cfg!(windows) { "odl.exe" } else { "odl" };
let new_binary = find_file(staging.path(), binary_name, 4)
.ok_or_else(|| other("the release archive contained no odl binary"))?;
replace_binary(&new_binary, exe).await?;
Ok(exe.to_path_buf())
}
fn find_file(root: &Path, name: &str, depth: usize) -> Option<PathBuf> {
if depth == 0 {
return None;
}
let entries = std::fs::read_dir(root).ok()?;
let mut dirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path.file_name().is_some_and(|f| f == name) {
return Some(path);
}
}
dirs.into_iter()
.find_map(|d| find_file(&d, name, depth - 1))
}
async fn replace_binary(new_binary: &Path, exe: &Path) -> Result<(), OdlError> {
let dir = exe
.parent()
.ok_or_else(|| other("the running binary has no parent directory"))?;
let staged = dir.join(".odl-update-staged");
tokio::fs::copy(new_binary, &staged).await.map_err(|e| {
other(format!(
"could not write the new binary to {}: {e}",
staged.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = match tokio::fs::metadata(exe).await {
Ok(meta) => meta.permissions().mode(),
Err(_) => 0o755,
};
if let Err(e) =
tokio::fs::set_permissions(&staged, std::fs::Permissions::from_mode(mode)).await
{
let _ = tokio::fs::remove_file(&staged).await;
return Err(other(format!(
"could not set permissions on the update: {e}"
)));
}
}
if cfg!(windows) {
let aside = exe.with_extension("old");
let _ = tokio::fs::remove_file(&aside).await;
if let Err(e) = tokio::fs::rename(exe, &aside).await {
let _ = tokio::fs::remove_file(&staged).await;
return Err(other(format!(
"could not move the running binary aside: {e}"
)));
}
if let Err(e) = tokio::fs::rename(&staged, exe).await {
let _ = tokio::fs::rename(&aside, exe).await;
let _ = tokio::fs::remove_file(&staged).await;
return Err(other(format!("could not install the update: {e}")));
}
} else if let Err(e) = tokio::fs::rename(&staged, exe).await {
let _ = tokio::fs::remove_file(&staged).await;
return Err(other(format!("could not install the update: {e}")));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_package_managers_binary_is_left_alone() {
assert_eq!(
managed_by(Path::new("/home/u/.cargo/bin/odl")),
Some("cargo install")
);
assert_eq!(
managed_by(Path::new("/usr/bin/odl")),
Some("a system package")
);
assert_eq!(
managed_by(Path::new("/nix/store/abc123-odl-2.0.0/bin/odl")),
Some("Nix")
);
assert_eq!(
managed_by(Path::new("/opt/homebrew/bin/odl")),
Some("Homebrew")
);
}
#[test]
fn a_script_install_outside_the_default_dir_is_not_package_managed() {
assert_eq!(managed_by(Path::new("/usr/local/bin/odl")), None);
assert_eq!(managed_by(Path::new("/home/u/.local/bin/odl")), None);
}
#[test]
fn versions_are_ordered_as_numbers_not_text() {
assert!(is_newer("v2.10.0", "2.9.0"));
assert!(is_newer("2.1.0", "2.0.9"));
assert!(!is_newer("v2.1.0", "2.1.0"));
assert!(!is_newer("v2.0.9", "2.1.0"));
}
#[test]
fn an_unparseable_version_is_never_newer() {
assert!(!is_newer("nightly", "2.1.0"));
assert!(!is_newer("v2.1.0", "custom-build"));
}
#[test]
fn a_pre_release_suffix_does_not_break_the_comparison() {
assert!(is_newer("v2.2.0-rc1", "2.1.0"));
assert!(!is_newer("v2.1.0-rc1", "2.1.0"));
}
#[test]
fn the_digest_is_read_from_the_published_checksum_file() {
let hex = "a".repeat(64);
let body = format!("{hex} odl-v2.1.1-x86_64-unknown-linux-gnu.tar.gz\n");
assert_eq!(
digest_from_checksum_file(&body, "odl-v2.1.1-x86_64-unknown-linux-gnu.tar.gz"),
Some(hex.clone())
);
assert_eq!(digest_from_checksum_file(&hex, "anything"), Some(hex));
}
#[test]
fn a_digest_for_another_file_is_not_accepted() {
let hex = "b".repeat(64);
let body = format!("{hex} odl-v2.1.1-aarch64-apple-darwin.tar.gz\n");
assert_eq!(
digest_from_checksum_file(&body, "odl-v2.1.1-x86_64-unknown-linux-gnu.tar.gz"),
None
);
}
#[test]
fn a_truncated_or_malformed_digest_is_not_accepted() {
assert_eq!(
digest_from_checksum_file("abc123 odl.tar.gz", "odl.tar.gz"),
None
);
assert_eq!(
digest_from_checksum_file(&format!("{} odl.tar.gz", "z".repeat(64)), "odl.tar.gz"),
None
);
}
fn receipt_for(dir: &Path) -> InstallReceipt {
InstallReceipt {
install_dir: dir.to_path_buf(),
tag: Some("v2.1.0".to_string()),
installer: Some("install.sh".to_string()),
}
}
#[test]
fn a_binary_nobody_claims_is_refused() {
let exe = Path::new("/home/u/build/odl");
assert_eq!(
decide(exe, None, &[]),
Err(Ineligible::UnknownInstall(exe.to_path_buf()))
);
}
#[test]
fn the_receipts_own_install_is_replaceable() {
let exe = Path::new("/opt/odl-bin/odl");
let receipt = receipt_for(Path::new("/opt/odl-bin"));
assert_eq!(decide(exe, Some(&receipt), &[]), Ok(exe));
}
#[test]
fn a_receipt_for_another_directory_licenses_nothing() {
let exe = Path::new("/home/u/src/odl/target/release/odl");
let receipt = receipt_for(Path::new("/home/u/.local/bin"));
assert!(matches!(
decide(exe, Some(&receipt), &[]),
Err(Ineligible::UnknownInstall(_))
));
}
#[test]
fn the_installers_default_directory_needs_no_receipt() {
let default = PathBuf::from("/home/u/.local/bin");
let exe = default.join("odl");
assert_eq!(
decide(&exe, None, std::slice::from_ref(&default)),
Ok(exe.as_path())
);
}
#[test]
fn a_receipt_does_not_override_a_package_manager() {
let exe = Path::new("/home/u/.cargo/bin/odl");
let receipt = receipt_for(Path::new("/home/u/.cargo/bin"));
assert_eq!(
decide(exe, Some(&receipt), &[]),
Err(Ineligible::ManagedBy("cargo install"))
);
}
#[test]
fn the_receipt_the_scripts_write_is_readable() {
let receipt: InstallReceipt = toml::from_str(
"install_dir = \"/home/u/.local/bin\"\ntag = \"v2.1.0\"\ninstaller = \"install.sh\"\n",
)
.expect("the installers' receipt must parse");
assert_eq!(receipt.install_dir, PathBuf::from("/home/u/.local/bin"));
assert_eq!(receipt.tag.as_deref(), Some("v2.1.0"));
}
#[test]
fn a_receipt_with_only_the_directory_still_works() {
let receipt: InstallReceipt =
toml::from_str("install_dir = \"/home/u/.local/bin\"\n").expect("must parse");
assert!(receipt.tag.is_none());
}
#[tokio::test]
async fn an_unwritable_install_is_refused_before_anything_is_downloaded() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("odl");
tokio::fs::write(&exe, b"binary").await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500))
.await
.unwrap();
assert!(!is_replaceable(&exe).await);
tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
.await
.unwrap();
}
assert!(is_replaceable(&exe).await);
}
fn listing(server: &mockito::Server, tag: &str, assets: &[&str]) -> String {
let assets: Vec<String> = assets
.iter()
.map(|name| {
format!(
r#"{{"name":"{name}","browser_download_url":"{}/{name}","size":10}}"#,
server.url()
)
})
.collect();
format!(r#"{{"tag_name":"{tag}","assets":[{}]}}"#, assets.join(","))
}
const TARGET: &str = "x86_64-unknown-linux-gnu";
fn asset_for(tag: &str) -> String {
format!("odl-{tag}-{TARGET}.{}", asset_extension())
}
#[tokio::test]
async fn a_newer_release_is_planned_with_its_published_digest() {
let mut server = mockito::Server::new_async().await;
let asset = asset_for("v9.9.9");
let hex = "c".repeat(64);
let _list = server
.mock("GET", "/releases/latest")
.with_body(listing(
&server,
"v9.9.9",
&[&asset, &format!("{asset}.sha256")],
))
.create_async()
.await;
let _sum = server
.mock("GET", format!("/{asset}.sha256").as_str())
.with_body(format!("{hex} {asset}\n"))
.create_async()
.await;
let plan = plan_from(
&reqwest::Client::new(),
&format!("{}/releases/latest", server.url()),
TARGET,
"2.1.0",
)
.await
.expect("a well-formed release must plan")
.expect("9.9.9 is newer than 2.1.0");
assert_eq!(plan.version, "9.9.9");
assert_eq!(plan.name, asset);
assert_eq!(plan.sha256, hex);
}
#[tokio::test]
async fn a_release_without_a_checksum_is_refused() {
let mut server = mockito::Server::new_async().await;
let asset = asset_for("v9.9.9");
let _list = server
.mock("GET", "/releases/latest")
.with_body(listing(&server, "v9.9.9", &[&asset]))
.create_async()
.await;
let err = plan_from(
&reqwest::Client::new(),
&format!("{}/releases/latest", server.url()),
TARGET,
"2.1.0",
)
.await
.expect_err("an unverifiable release must not be planned");
assert!(
err.to_string().contains("no checksum"),
"the reason must name the missing checksum: {err}"
);
}
#[tokio::test]
async fn a_release_without_a_build_for_this_machine_is_refused() {
let mut server = mockito::Server::new_async().await;
let other = "odl-v9.9.9-aarch64-apple-darwin.tar.gz";
let _list = server
.mock("GET", "/releases/latest")
.with_body(listing(
&server,
"v9.9.9",
&[other, &format!("{other}.sha256")],
))
.create_async()
.await;
let err = plan_from(
&reqwest::Client::new(),
&format!("{}/releases/latest", server.url()),
TARGET,
"2.1.0",
)
.await
.expect_err("there is nothing here this machine can run");
assert!(err.to_string().contains(TARGET), "got: {err}");
}
#[tokio::test]
async fn the_release_in_use_plans_nothing() {
let mut server = mockito::Server::new_async().await;
let asset = asset_for("v2.1.0");
let _list = server
.mock("GET", "/releases/latest")
.with_body(listing(
&server,
"v2.1.0",
&[&asset, &format!("{asset}.sha256")],
))
.create_async()
.await;
let plan = plan_from(
&reqwest::Client::new(),
&format!("{}/releases/latest", server.url()),
TARGET,
"2.1.0",
)
.await
.expect("an up-to-date check is not an error");
assert!(plan.is_none());
}
#[tokio::test]
async fn a_replaced_binary_keeps_its_path_and_contents() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("odl");
tokio::fs::write(&exe, b"old").await.unwrap();
let fresh = dir.path().join("fresh");
tokio::fs::write(&fresh, b"new").await.unwrap();
replace_binary(&fresh, &exe).await.unwrap();
assert_eq!(tokio::fs::read(&exe).await.unwrap(), b"new");
assert!(!dir.path().join(".odl-update-staged").exists());
}
}