1use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12
13#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum UpdateError {
17 #[error("{0}")]
19 Fetch(String),
20 #[error("no release binary for {0}; build from source (scripts/install.sh)")]
22 NoArtifact(String),
23 #[error("sha256 mismatch for the downloaded binary")]
25 Checksum,
26 #[error("{context}: {source}")]
28 Io {
29 context: String,
31 source: std::io::Error,
33 },
34}
35
36#[derive(Clone, Debug, Deserialize)]
38pub struct ReleaseManifest {
39 pub version: String,
41 #[serde(default)]
43 pub artifacts: Vec<Artifact>,
44}
45
46#[derive(Clone, Debug, Deserialize)]
48pub struct Artifact {
49 #[serde(default)]
51 pub name: String,
52 pub target: String,
54 pub url: String,
56 #[serde(default)]
58 pub sha256: String,
59}
60
61#[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#[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
138fn 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
174pub 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}