use std::path::{Path, PathBuf};
use serde::Deserialize;
use sha2::{Digest, Sha256};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum UpdateError {
#[error("{0}")]
Fetch(String),
#[error("no release binary for {0}; build from source (scripts/install.sh)")]
NoArtifact(String),
#[error("sha256 mismatch for the downloaded binary")]
Checksum,
#[error("{context}: {source}")]
Io {
context: String,
source: std::io::Error,
},
}
#[derive(Clone, Debug, Deserialize)]
pub struct ReleaseManifest {
pub version: String,
#[serde(default)]
pub artifacts: Vec<Artifact>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Artifact {
#[serde(default)]
pub name: String,
pub target: String,
pub url: String,
#[serde(default)]
pub sha256: String,
}
#[must_use]
pub fn current_target() -> String {
let arch = std::env::consts::ARCH;
match std::env::consts::OS {
"linux" => format!("{arch}-unknown-linux-gnu"),
"macos" => format!("{arch}-apple-darwin"),
"windows" => format!("{arch}-pc-windows-msvc"),
other => format!("{arch}-{other}"),
}
}
fn manifest_url() -> String {
if let Ok(url) = std::env::var("OINO_RELEASE_MANIFEST")
&& !url.is_empty()
{
return url;
}
let slug = env!("CARGO_PKG_REPOSITORY").trim_start_matches("https://github.com/");
match std::env::var("OINO_REF").ok().filter(|r| !r.is_empty()) {
Some(reference) => {
format!("https://github.com/{slug}/releases/download/{reference}/release-manifest.json")
}
None => format!("https://github.com/{slug}/releases/latest/download/release-manifest.json"),
}
}
async fn fetch_bytes(url: &str) -> Result<Vec<u8>, UpdateError> {
if let Some(path) = url.strip_prefix("file://") {
return std::fs::read(path).map_err(|e| UpdateError::Fetch(format!("{url}: {e}")));
}
let response = reqwest::get(url)
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| UpdateError::Fetch(format!("{url}: {e}")))?;
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| UpdateError::Fetch(format!("{url}: {e}")))
}
#[must_use]
pub fn is_newer(candidate: &str, current: &str) -> bool {
fn parts(v: &str) -> (Vec<u64>, Option<Vec<String>>) {
let v = v.trim_start_matches('v');
let (core, pre) = v.split_once('-').map_or((v, None), |(c, p)| (c, Some(p)));
(
core.split('.').map(|p| p.parse().unwrap_or(0)).collect(),
pre.map(|p| p.split('.').map(str::to_owned).collect()),
)
}
let (a_core, a_pre) = parts(candidate);
let (b_core, b_pre) = parts(current);
if a_core != b_core {
return a_core > b_core;
}
match (a_pre, b_pre) {
(None, Some(_)) => true,
(Some(a), Some(b)) => {
for (x, y) in a.iter().zip(&b) {
let ordering = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(x), Ok(y)) => x.cmp(&y),
_ => x.cmp(y),
};
if ordering != std::cmp::Ordering::Equal {
return ordering == std::cmp::Ordering::Greater;
}
}
a.len() > b.len()
}
_ => false,
}
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes.iter().fold(String::new(), |mut out, b| {
let _ = write!(out, "{b:02x}");
out
})
}
fn replace_binary(exe: &Path, bytes: &[u8]) -> Result<(), UpdateError> {
let io = |context: &str| {
let context = format!("{context} {}", exe.display());
move |source| UpdateError::Io { context, source }
};
let new = exe.with_extension("new");
let old = exe.with_extension("old");
std::fs::write(&new, bytes).map_err(io("writing next to"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755))
.map_err(io("marking executable"))?;
}
let _ = std::fs::remove_file(&old);
std::fs::rename(exe, &old).map_err(io("moving aside"))?;
if let Err(source) = std::fs::rename(&new, exe) {
let _ = std::fs::rename(&old, exe);
return Err(UpdateError::Io {
context: format!("installing {}", exe.display()),
source,
});
}
let _ = std::fs::remove_file(&old);
Ok(())
}
pub async fn self_update(check: bool, exe: Option<PathBuf>) -> Result<String, UpdateError> {
let current = crate::VERSION;
let bytes = fetch_bytes(&manifest_url()).await?;
let manifest: ReleaseManifest = serde_json::from_slice(&bytes)
.map_err(|e| UpdateError::Fetch(format!("invalid release manifest: {e}")))?;
if !is_newer(&manifest.version, current) {
return Ok(format!("Oino is up to date ({current})"));
}
if check {
return Ok(format!(
"Update available: {current} → {} (run `oino update`)",
manifest.version
));
}
let target = current_target();
let artifact = manifest
.artifacts
.iter()
.find(|a| a.target == target && (a.name.is_empty() || a.name == "oino"))
.ok_or_else(|| UpdateError::NoArtifact(target.clone()))?;
let binary = fetch_bytes(&artifact.url).await?;
if !artifact.sha256.is_empty()
&& !hex(&Sha256::digest(&binary)).eq_ignore_ascii_case(artifact.sha256.trim())
{
return Err(UpdateError::Checksum);
}
let exe = match exe {
Some(exe) => exe,
None => std::env::current_exe().map_err(|source| UpdateError::Io {
context: "locating the running binary".to_owned(),
source,
})?,
};
replace_binary(&exe, &binary)?;
Ok(format!("Updated Oino {current} → {}", manifest.version))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_ordering() {
assert!(is_newer("1.0.0-alpha.2", "1.0.0-alpha.1"));
assert!(is_newer("1.0.0", "1.0.0-alpha.9"));
assert!(is_newer("v1.1.0", "1.0.9"));
assert!(!is_newer("1.0.0-alpha.1", "1.0.0-alpha.1"));
assert!(!is_newer("1.0.0-alpha.1", "1.0.0"));
assert!(is_newer("1.0.0-alpha.10", "1.0.0-alpha.9"));
}
#[tokio::test]
async fn installs_from_a_file_manifest() {
let dir = tempfile::tempdir().unwrap();
let binary = b"#!/bin/sh\necho new\n";
let digest = hex(&Sha256::digest(binary));
std::fs::write(dir.path().join("oino-bin"), binary).unwrap();
let exe = dir.path().join("oino");
std::fs::write(&exe, b"old").unwrap();
let manifest = serde_json::json!({
"version": "99.0.0",
"artifacts": [{"name": "oino", "target": current_target(),
"url": format!("file://{}", dir.path().join("oino-bin").display()), "sha256": digest}]
});
let manifest_path = dir.path().join("release-manifest.json");
std::fs::write(&manifest_path, manifest.to_string()).unwrap();
let url = format!("file://{}", manifest_path.display());
let bytes = fetch_bytes(&url).await.unwrap();
let parsed: ReleaseManifest = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed.version, "99.0.0");
let fetched = fetch_bytes(&parsed.artifacts[0].url).await.unwrap();
replace_binary(&exe, &fetched).unwrap();
assert_eq!(std::fs::read(&exe).unwrap(), binary);
assert!(!exe.with_extension("old").exists());
}
}