Skip to main content

oino/cli/
self_update.rs

1//! `oino update [--check]` (modes.md §1.2): self-update from the GitHub release manifest.
2//!
3//! Reads `release-manifest.json` of the latest release (or `OINO_REF`), picks the `oino`
4//! artifact of this target, verifies its sha256 and atomically replaces the running binary
5//! (renaming the old one away first, which also works for a running `.exe` on Windows).
6//! `OINO_RELEASE_MANIFEST` overrides the manifest URL (mirrors, tests).
7
8use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12
13/// Failure of a self-update.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum UpdateError {
17    /// The manifest or binary could not be fetched.
18    #[error("{0}")]
19    Fetch(String),
20    /// No artifact for this platform.
21    #[error("no release binary for {0}; build from source (scripts/install.sh)")]
22    NoArtifact(String),
23    /// The download does not match the manifest.
24    #[error("sha256 mismatch for the downloaded binary")]
25    Checksum,
26    /// Replacing the binary failed.
27    #[error("{context}: {source}")]
28    Io {
29        /// What was being done.
30        context: String,
31        /// Cause.
32        source: std::io::Error,
33    },
34}
35
36/// `release-manifest.json`.
37#[derive(Clone, Debug, Deserialize)]
38pub struct ReleaseManifest {
39    /// Release version (tag without `v`).
40    pub version: String,
41    /// Binaries.
42    #[serde(default)]
43    pub artifacts: Vec<Artifact>,
44}
45
46/// One release binary.
47#[derive(Clone, Debug, Deserialize)]
48pub struct Artifact {
49    /// `oino` | `oino-kanboard`.
50    #[serde(default)]
51    pub name: String,
52    /// Rust target triple.
53    pub target: String,
54    /// Download URL.
55    pub url: String,
56    /// Hex sha256.
57    #[serde(default)]
58    pub sha256: String,
59}
60
61/// This build's release target (`x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, …).
62#[must_use]
63pub fn current_target() -> String {
64    let arch = std::env::consts::ARCH;
65    match std::env::consts::OS {
66        "linux" => format!("{arch}-unknown-linux-gnu"),
67        "macos" => format!("{arch}-apple-darwin"),
68        "windows" => format!("{arch}-pc-windows-msvc"),
69        other => format!("{arch}-{other}"),
70    }
71}
72
73fn manifest_url() -> String {
74    if let Ok(url) = std::env::var("OINO_RELEASE_MANIFEST")
75        && !url.is_empty()
76    {
77        return url;
78    }
79    let slug = env!("CARGO_PKG_REPOSITORY").trim_start_matches("https://github.com/");
80    match std::env::var("OINO_REF").ok().filter(|r| !r.is_empty()) {
81        Some(reference) => {
82            format!("https://github.com/{slug}/releases/download/{reference}/release-manifest.json")
83        }
84        None => format!("https://github.com/{slug}/releases/latest/download/release-manifest.json"),
85    }
86}
87
88async fn fetch_bytes(url: &str) -> Result<Vec<u8>, UpdateError> {
89    if let Some(path) = url.strip_prefix("file://") {
90        return std::fs::read(path).map_err(|e| UpdateError::Fetch(format!("{url}: {e}")));
91    }
92    let response = reqwest::get(url)
93        .await
94        .and_then(reqwest::Response::error_for_status)
95        .map_err(|e| UpdateError::Fetch(format!("{url}: {e}")))?;
96    response
97        .bytes()
98        .await
99        .map(|b| b.to_vec())
100        .map_err(|e| UpdateError::Fetch(format!("{url}: {e}")))
101}
102
103/// Compares dotted versions with pre-release suffixes (`1.0.0-alpha.2` > `1.0.0-alpha.1`,
104/// `1.0.0` > `1.0.0-alpha.9`).
105#[must_use]
106pub fn is_newer(candidate: &str, current: &str) -> bool {
107    fn parts(v: &str) -> (Vec<u64>, Option<Vec<String>>) {
108        let v = v.trim_start_matches('v');
109        let (core, pre) = v.split_once('-').map_or((v, None), |(c, p)| (c, Some(p)));
110        (
111            core.split('.').map(|p| p.parse().unwrap_or(0)).collect(),
112            pre.map(|p| p.split('.').map(str::to_owned).collect()),
113        )
114    }
115    let (a_core, a_pre) = parts(candidate);
116    let (b_core, b_pre) = parts(current);
117    if a_core != b_core {
118        return a_core > b_core;
119    }
120    match (a_pre, b_pre) {
121        (None, Some(_)) => true,
122        (Some(a), Some(b)) => {
123            for (x, y) in a.iter().zip(&b) {
124                let ordering = match (x.parse::<u64>(), y.parse::<u64>()) {
125                    (Ok(x), Ok(y)) => x.cmp(&y),
126                    _ => x.cmp(y),
127                };
128                if ordering != std::cmp::Ordering::Equal {
129                    return ordering == std::cmp::Ordering::Greater;
130                }
131            }
132            a.len() > b.len()
133        }
134        _ => false,
135    }
136}
137
138/// Lower-case hex of `bytes`.
139fn hex(bytes: &[u8]) -> String {
140    use std::fmt::Write as _;
141    bytes.iter().fold(String::new(), |mut out, b| {
142        let _ = write!(out, "{b:02x}");
143        out
144    })
145}
146
147fn replace_binary(exe: &Path, bytes: &[u8]) -> Result<(), UpdateError> {
148    let io = |context: &str| {
149        let context = format!("{context} {}", exe.display());
150        move |source| UpdateError::Io { context, source }
151    };
152    let new = exe.with_extension("new");
153    let old = exe.with_extension("old");
154    std::fs::write(&new, bytes).map_err(io("writing next to"))?;
155    #[cfg(unix)]
156    {
157        use std::os::unix::fs::PermissionsExt;
158        std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755))
159            .map_err(io("marking executable"))?;
160    }
161    let _ = std::fs::remove_file(&old);
162    std::fs::rename(exe, &old).map_err(io("moving aside"))?;
163    if let Err(source) = std::fs::rename(&new, exe) {
164        let _ = std::fs::rename(&old, exe);
165        return Err(UpdateError::Io {
166            context: format!("installing {}", exe.display()),
167            source,
168        });
169    }
170    let _ = std::fs::remove_file(&old);
171    Ok(())
172}
173
174/// Checks for (and unless `check`, installs) a newer release; returns the message to print.
175///
176/// # Errors
177///
178/// Fetch, platform, checksum and replacement failures.
179pub async fn self_update(check: bool, exe: Option<PathBuf>) -> Result<String, UpdateError> {
180    let current = crate::VERSION;
181    let bytes = fetch_bytes(&manifest_url()).await?;
182    let manifest: ReleaseManifest = serde_json::from_slice(&bytes)
183        .map_err(|e| UpdateError::Fetch(format!("invalid release manifest: {e}")))?;
184    if !is_newer(&manifest.version, current) {
185        return Ok(format!("Oino is up to date ({current})"));
186    }
187    if check {
188        return Ok(format!(
189            "Update available: {current} → {} (run `oino update`)",
190            manifest.version
191        ));
192    }
193    let target = current_target();
194    let artifact = manifest
195        .artifacts
196        .iter()
197        .find(|a| a.target == target && (a.name.is_empty() || a.name == "oino"))
198        .ok_or_else(|| UpdateError::NoArtifact(target.clone()))?;
199    let binary = fetch_bytes(&artifact.url).await?;
200    if !artifact.sha256.is_empty()
201        && !hex(&Sha256::digest(&binary)).eq_ignore_ascii_case(artifact.sha256.trim())
202    {
203        return Err(UpdateError::Checksum);
204    }
205    let exe = match exe {
206        Some(exe) => exe,
207        None => std::env::current_exe().map_err(|source| UpdateError::Io {
208            context: "locating the running binary".to_owned(),
209            source,
210        })?,
211    };
212    replace_binary(&exe, &binary)?;
213    Ok(format!("Updated Oino {current} → {}", manifest.version))
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn version_ordering() {
222        assert!(is_newer("1.0.0-alpha.2", "1.0.0-alpha.1"));
223        assert!(is_newer("1.0.0", "1.0.0-alpha.9"));
224        assert!(is_newer("v1.1.0", "1.0.9"));
225        assert!(!is_newer("1.0.0-alpha.1", "1.0.0-alpha.1"));
226        assert!(!is_newer("1.0.0-alpha.1", "1.0.0"));
227        assert!(is_newer("1.0.0-alpha.10", "1.0.0-alpha.9"));
228    }
229
230    #[tokio::test]
231    async fn installs_from_a_file_manifest() {
232        let dir = tempfile::tempdir().unwrap();
233        let binary = b"#!/bin/sh\necho new\n";
234        let digest = hex(&Sha256::digest(binary));
235        std::fs::write(dir.path().join("oino-bin"), binary).unwrap();
236        let exe = dir.path().join("oino");
237        std::fs::write(&exe, b"old").unwrap();
238        let manifest = serde_json::json!({
239            "version": "99.0.0",
240            "artifacts": [{"name": "oino", "target": current_target(),
241                "url": format!("file://{}", dir.path().join("oino-bin").display()), "sha256": digest}]
242        });
243        let manifest_path = dir.path().join("release-manifest.json");
244        std::fs::write(&manifest_path, manifest.to_string()).unwrap();
245        let url = format!("file://{}", manifest_path.display());
246        let bytes = fetch_bytes(&url).await.unwrap();
247        let parsed: ReleaseManifest = serde_json::from_slice(&bytes).unwrap();
248        assert_eq!(parsed.version, "99.0.0");
249        let fetched = fetch_bytes(&parsed.artifacts[0].url).await.unwrap();
250        replace_binary(&exe, &fetched).unwrap();
251        assert_eq!(std::fs::read(&exe).unwrap(), binary);
252        assert!(!exe.with_extension("old").exists());
253    }
254}