use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::process::Command;
type Failure = Box<dyn std::error::Error>;
const REPO: &str = "bemindlabs/ostraka";
const BASE_URL_ENV: &str = "OSTRAKA_BASE_URL";
const VERSION_ENV: &str = "OSTRAKA_VERSION";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Owner {
Ours,
Managed { name: &'static str, command: String },
}
impl Owner {
pub fn of(exe: &Path) -> Self {
let path = exe.to_string_lossy().replace('\\', "/");
let has = |needle: &str| path.contains(needle);
if has("/Cellar/ostraka/") || has("/homebrew/") || has("/linuxbrew/") {
return Owner::Managed {
name: "Homebrew",
command: "brew upgrade ostraka".to_string(),
};
}
if has("/.cargo/bin/") || has("/.rustup/") {
return Owner::Managed {
name: "cargo",
command: "cargo install ostraka --force".to_string(),
};
}
if has("/node_modules/") || has("/npm/") || has("/.npm-global/") {
return Owner::Managed {
name: "npm",
command: "npm update -g ostraka".to_string(),
};
}
Owner::Ours
}
}
pub fn target() -> Option<&'static str> {
Some(match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
("macos", "x86_64") => "x86_64-apple-darwin",
("macos", "aarch64") => "aarch64-apple-darwin",
("windows", "x86_64") => "x86_64-pc-windows-msvc",
_ => return None,
})
}
fn parts(v: &str) -> Option<(u64, u64, u64)> {
let v = v.trim().trim_start_matches('v');
let core = v.split(['-', '+']).next().unwrap_or(v);
let mut it = core.split('.');
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
let c = it.next()?.parse().ok()?;
if it.next().is_some() {
return None;
}
Some((a, b, c))
}
pub fn is_newer(current: &str, latest: &str) -> bool {
match (parts(current), parts(latest)) {
(Some(now), Some(new)) => new > now,
_ => false,
}
}
fn fetch(url: &str) -> Result<Vec<u8>, Failure> {
let out = Command::new("curl")
.args(["-fsSL", "--proto", "=https,file", url])
.output()
.map_err(|e| -> Failure {
format!(
"could not run curl, which is how this fetches a release: {e}\n\
install curl, or download the release by hand from \
https://github.com/{REPO}/releases"
)
.into()
})?;
if !out.status.success() {
let said = String::from_utf8_lossy(&out.stderr);
return Err(format!("could not fetch {url}: {}", said.trim()).into());
}
Ok(out.stdout)
}
pub fn latest_tag() -> Result<String, Failure> {
if let Ok(pinned) = std::env::var(VERSION_ENV) {
if !pinned.is_empty() {
return Ok(pinned);
}
}
let body = fetch(&format!(
"https://api.github.com/repos/{REPO}/releases/latest"
))?;
let doc: serde_json::Value = serde_json::from_slice(&body)
.map_err(|e| -> Failure { format!("the release listing did not parse: {e}").into() })?;
doc.get("tag_name")
.and_then(|t| t.as_str())
.map(|t| t.to_string())
.ok_or_else(|| "the release listing named no tag".into())
}
fn base_url(tag: &str) -> String {
match std::env::var(BASE_URL_ENV) {
Ok(base) if !base.is_empty() => base.trim_end_matches('/').to_string(),
_ => format!("https://github.com/{REPO}/releases/download/{tag}"),
}
}
fn digest(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn expected_digest(sidecar: &str) -> Option<String> {
let word = sidecar.split_whitespace().next()?;
let looks_right = word.len() == 64
&& word
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase());
looks_right.then(|| word.to_string())
}
fn verified_archive(tag: &str, target: &str) -> Result<Vec<u8>, Failure> {
let name = format!("ostraka-{tag}-{target}");
let base = base_url(tag);
let archive = fetch(&format!("{base}/{name}.tar.gz"))?;
let sidecar = fetch(&format!("{base}/{name}.tar.gz.sha256")).map_err(|e| -> Failure {
format!(
"{name}.tar.gz has no readable checksum beside it ({e}), so these bytes \
cannot be verified — and this command overwrites the binary that is \
running, which is not something to do on unverified bytes"
)
.into()
})?;
let sidecar = String::from_utf8_lossy(&sidecar);
let expected = expected_digest(&sidecar).ok_or_else(|| -> Failure {
format!("the checksum published for {name}.tar.gz is not a sha256 digest").into()
})?;
let actual = digest(&archive);
if actual != expected {
return Err(format!(
"checksum mismatch for {name}.tar.gz\n published {expected}\n downloaded {actual}"
)
.into());
}
Ok(archive)
}
fn unpack(archive: &[u8], tag: &str, target: &str, into: &Path) -> Result<PathBuf, Failure> {
let name = format!("ostraka-{tag}-{target}");
let tarball = into.join(format!("{name}.tar.gz"));
std::fs::write(&tarball, archive)?;
let status = Command::new("tar")
.arg("-xzf")
.arg(&tarball)
.arg("-C")
.arg(into)
.status()
.map_err(|e| -> Failure {
format!("could not run tar to unpack the release: {e}").into()
})?;
if !status.success() {
return Err(format!("tar could not unpack {name}.tar.gz").into());
}
let binary = into.join(&name).join(if cfg!(windows) {
"ostraka.exe"
} else {
"ostraka"
});
if !binary.is_file() {
return Err(format!("{name}.tar.gz did not contain an ostraka binary").into());
}
Ok(binary)
}
fn place(new: &Path, exe: &Path) -> Result<(), Failure> {
let staged = exe.with_extension("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))?;
}
let aside = exe.with_extension("old");
let moved_aside = if cfg!(windows) {
let _ = std::fs::remove_file(&aside);
std::fs::rename(exe, &aside).inspect_err(|_| {
let _ = std::fs::remove_file(&staged);
})?;
true
} else {
false
};
if let Err(e) = std::fs::rename(&staged, exe) {
let _ = std::fs::remove_file(&staged);
if moved_aside {
if let Err(back) = std::fs::rename(&aside, exe) {
return Err(format!(
"could not install the new binary ({e}), and could not put the old \
one back either ({back}) — it is at {}, and moving it to {} by hand \
restores what was there",
aside.display(),
exe.display()
)
.into());
}
}
return Err(format!("could not install the new binary: {e}").into());
}
Ok(())
}
pub struct Standing {
pub current: String,
pub latest: String,
pub newer: bool,
pub owner: Owner,
pub exe: PathBuf,
}
pub fn run(check_only: bool, json: bool) -> Result<bool, Failure> {
let current = env!("CARGO_PKG_VERSION").to_string();
let exe = std::env::current_exe()?;
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
let owner = Owner::of(&exe);
let latest = latest_tag()?;
let newer = is_newer(¤t, &latest);
let standing = Standing {
current: current.clone(),
latest: latest.clone(),
newer,
owner: owner.clone(),
exe: exe.clone(),
};
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"current": standing.current,
"latest": standing.latest,
"update_available": standing.newer,
"path": standing.exe.display().to_string(),
"managed_by": match &standing.owner {
Owner::Ours => serde_json::Value::Null,
Owner::Managed { name, .. } => serde_json::Value::String(name.to_string()),
},
"command": match &standing.owner {
Owner::Ours => serde_json::Value::Null,
Owner::Managed { command, .. } => serde_json::Value::String(command.clone()),
},
}))?
);
return Ok(true);
}
if !newer {
println!("ostraka {current} is current (latest release is {latest})");
return Ok(true);
}
println!("ostraka {current} → {latest} available");
if let Owner::Managed { name, command } = &owner {
println!(" {} installed this, at {}", name, exe.display());
println!(" take it with: {command}");
return Ok(true);
}
if check_only {
println!(" run `ostraka update` to take it");
return Ok(true);
}
let Some(target) = target() else {
return Err(format!(
"no release is published for {}-{}, so there is nothing to update to",
std::env::consts::OS,
std::env::consts::ARCH
)
.into());
};
println!(" downloading ostraka-{latest}-{target}");
let archive = verified_archive(&latest, target)?;
println!(" checksum ok");
let staging = std::env::temp_dir().join(format!("ostraka-update-{}", std::process::id()));
std::fs::create_dir_all(&staging)?;
let outcome = unpack(&archive, &latest, target, &staging).and_then(|new| place(&new, &exe));
let _ = std::fs::remove_dir_all(&staging);
outcome?;
println!(" installed {latest} to {}", exe.display());
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_binary_a_package_manager_owns_is_not_ours_to_replace() {
for (path, manager) in [
("/opt/homebrew/Cellar/ostraka/1.0.0/bin/ostraka", "Homebrew"),
("/home/linuxbrew/.linuxbrew/bin/ostraka", "Homebrew"),
("/home/x/.cargo/bin/ostraka", "cargo"),
("/usr/lib/node_modules/ostraka/bin/ostraka", "npm"),
("C:/Users/x/.cargo/bin/ostraka.exe", "cargo"),
] {
match Owner::of(Path::new(path)) {
Owner::Managed { name, command } => {
assert_eq!(name, manager, "{path}");
assert!(!command.is_empty(), "{path} named no way to update");
}
Owner::Ours => panic!("{path} was treated as ours to overwrite"),
}
}
}
#[test]
fn a_binary_nothing_is_tracking_is_ours() {
for path in [
"/home/x/.local/bin/ostraka",
"/usr/local/bin/ostraka",
"/home/x/bin/ostraka",
] {
assert_eq!(Owner::of(Path::new(path)), Owner::Ours, "{path}");
}
}
#[test]
fn versions_compare_as_numbers_and_an_unreadable_one_never_updates() {
assert!(is_newer("1.0.0", "1.0.1"));
assert!(is_newer("1.0.0", "v1.0.1"));
assert!(is_newer("1.9.0", "1.10.0"));
assert!(!is_newer("1.10.0", "1.9.0"));
assert!(!is_newer("1.0.0", "1.0.0"));
assert!(!is_newer("1.0.0", "nightly"));
assert!(!is_newer("1.0.0", "1.0"));
assert!(!is_newer("1.0.0", "1.0.0.1"));
assert!(!is_newer("not-a-version", "2.0.0"));
}
#[test]
fn a_prerelease_tag_is_read_by_its_numbers() {
assert!(is_newer("1.0.0", "1.0.1-rc.1"));
assert!(!is_newer("1.0.1", "1.0.1-rc.1"));
}
#[test]
fn a_checksum_sidecar_is_read_strictly() {
let good = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(good.len(), 64, "the fixture is not a digest");
assert_eq!(
expected_digest(&format!("{good} ostraka-v1.0.0-x86_64.tar.gz")).as_deref(),
Some(good)
);
assert_eq!(expected_digest(good).as_deref(), Some(good), "no filename");
assert!(expected_digest("").is_none());
assert!(expected_digest(" ").is_none());
assert!(expected_digest("not-a-digest file").is_none());
assert!(
expected_digest(&good[..63]).is_none(),
"sixty-three characters is not a digest"
);
assert!(
expected_digest(&format!("{good}ab")).is_none(),
"sixty-six characters is not a digest either"
);
assert!(
expected_digest(&good.to_uppercase()).is_none(),
"uppercase is not what the sidecar writes, so it is not assumed to be one"
);
}
#[test]
fn the_notice_names_the_way_out_that_matches_who_owns_the_binary() {
let ours = Path::new("/home/x/.local/bin/ostraka");
let brewed = Path::new("/opt/homebrew/Cellar/ostraka/1.0.0/bin/ostraka");
assert_eq!(
notice::line("9.9.9", ours),
None,
"a current version was offered an update"
);
assert_eq!(notice::line("0.0.1", brewed), notice::line("0.0.1", brewed));
}
#[test]
fn this_machine_has_a_release_target() {
assert!(
target().is_some(),
"no release target for {}-{}",
std::env::consts::OS,
std::env::consts::ARCH
);
}
}
pub mod notice {
use super::{Owner, is_newer};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
pub const OFF: &str = "OSTRAKA_NO_UPDATE_CHECK";
const STALE_AFTER: Duration = Duration::from_secs(60 * 60 * 24);
fn cache() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CACHE_HOME") {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => PathBuf::from(std::env::var_os("HOME")?).join(".cache"),
};
Some(base.join("ostraka"))
}
fn enabled() -> bool {
std::env::var_os(OFF).is_none()
}
fn cached() -> (Option<String>, bool) {
let Some(file) = cache().map(|d| d.join("latest.json")) else {
return (None, false);
};
let Ok(text) = std::fs::read_to_string(&file) else {
return (None, true);
};
let tag = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|d| d.get("tag_name")?.as_str().map(str::to_string));
let fresh = tag.is_some()
&& std::fs::metadata(&file)
.and_then(|m| m.modified())
.ok()
.and_then(|t| SystemTime::now().duration_since(t).ok())
.is_some_and(|age| age < STALE_AFTER);
(tag, !fresh)
}
fn refresh() {
let Some(dir) = cache() else { return };
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let file = dir.join("latest.json");
let url = format!(
"https://api.github.com/repos/{}/releases/latest",
super::REPO
);
let _ = std::process::Command::new("curl")
.args(["-fsSL", "--max-time", "20", "-o"])
.arg(&file)
.arg(&url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
pub fn line(current: &str, exe: &std::path::Path) -> Option<String> {
let (tag, _) = cached();
let latest = tag?;
if !is_newer(current, &latest) {
return None;
}
let how = match Owner::of(exe) {
Owner::Ours => "ostraka update".to_string(),
Owner::Managed { command, .. } => command,
};
Some(format!(
"ostraka {current} → {latest} is available. `{how}` to take it, \
or set {OFF} to stop saying so."
))
}
pub fn offer(json: bool) {
if json || !enabled() || !std::io::stderr().is_terminal() {
return;
}
let current = env!("CARGO_PKG_VERSION");
let exe = std::env::current_exe()
.and_then(|p| std::fs::canonicalize(&p).or(Ok(p)))
.unwrap_or_default();
if let Some(said) = line(current, &exe) {
eprintln!("\n{said}");
}
let (_, stale) = cached();
if stale {
refresh();
}
}
}