use anyhow::{anyhow, bail}; use reqwest::header::{AUTHORIZATION, USER_AGENT}; use serde::Deserialize; use std::env; use std::io::{self, Write}; use std::path::Path; const GITEA_HOST: &str = "https://git.ni.riken.jp"; const REPO_OWNER: &str = "niu"; const REPO_NAME: &str = "mdrs-client-rust"; /// Current build target triple, captured at compile time via build.rs. const BUILD_TARGET: &str = env!("BUILD_TARGET"); #[derive(Deserialize)] struct GiteaRelease { tag_name: String, assets: Vec, } #[derive(Deserialize)] struct GiteaAsset { name: String, browser_download_url: String, } /// Returns true if `latest` is strictly greater than `current` (semver-like comparison). fn is_newer(current: &str, latest: &str) -> bool { let parse = |s: &str| -> Vec { s.trim_start_matches('v') .split('.') .map(|p| p.parse::().unwrap_or(0)) .collect() }; let cur = parse(current); let lat = parse(latest); let len = cur.len().max(lat.len()); for i in 0..len { let c = cur.get(i).copied().unwrap_or(0); let l = lat.get(i).copied().unwrap_or(0); if l > c { return true; } if l < c { return false; } } false } /// Read the digest out of a `sha256sum` line: the hex digest, then the file it covers. fn parse_sha256_line(text: &str) -> Option { let digest = text.split_whitespace().next()?.to_ascii_lowercase(); let is_hex = digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit()); is_hex.then_some(digest) } /// Hex SHA-256 of a slice, in the form `sha256sum` prints. fn sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(bytes); hasher .finalize() .iter() .map(|b| format!("{:02x}", b)) .collect() } /// Extract the binary named `bin_name` from a `.tar.gz` archive at `archive_path` /// and write it to `dest_path`. fn extract_from_tar_gz( archive_path: &Path, bin_name: &str, dest_path: &Path, ) -> anyhow::Result<()> { use flate2::read::GzDecoder; use tar::Archive; let file = std::fs::File::open(archive_path)?; let gz = GzDecoder::new(file); let mut archive = Archive::new(gz); for entry in archive.entries()? { let mut entry = entry?; let path = entry.path()?; // Match by file name only (ignore directory prefix in archive). if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) { entry.unpack(dest_path)?; return Ok(()); } } bail!("Binary '{}' not found in archive", bin_name) } /// Extract the binary named `bin_name` from a `.zip` archive at `archive_path` /// and write it to `dest_path`. fn extract_from_zip(archive_path: &Path, bin_name: &str, dest_path: &Path) -> anyhow::Result<()> { use std::io::Read; let file = std::fs::File::open(archive_path)?; let mut archive = zip::ZipArchive::new(file)?; for i in 0..archive.len() { let mut entry = archive.by_index(i)?; let entry_name = entry.name().to_owned(); let file_name = Path::new(&entry_name) .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); if file_name == bin_name { let mut buf = Vec::new(); entry.read_to_end(&mut buf)?; std::fs::write(dest_path, &buf)?; return Ok(()); } } bail!("Binary '{}' not found in archive", bin_name) } pub async fn selfupdate(yes: bool) -> anyhow::Result<()> { let current_version = env!("CARGO_PKG_VERSION"); println!( "Checking for updates (current version: {current_version}, target: {BUILD_TARGET})..." ); let api_url = format!("{GITEA_HOST}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases?limit=1"); let client = reqwest::Client::new(); let mut req = client .get(&api_url) .header(USER_AGENT, format!("mdrs/{current_version}")); if let Ok(token) = env::var("GITEA_TOKEN") { req = req.header(AUTHORIZATION, format!("Bearer {token}")); } let resp = req.send().await?; if !resp.status().is_success() { bail!("Failed to fetch release info: HTTP {}", resp.status()); } let releases: Vec = resp.json().await?; let release = releases .into_iter() .next() .ok_or_else(|| anyhow!("No releases found"))?; let latest_version = release.tag_name.trim_start_matches('v'); if !is_newer(current_version, latest_version) { println!("Already up-to-date ({current_version})."); return Ok(()); } println!("New version available: {latest_version}"); // Find the asset matching the current build target. // The checksum asset carries the target in its name too, so say which one is wanted. let asset = release .assets .iter() .find(|a| a.name.contains(BUILD_TARGET) && !a.name.ends_with(".sha256")) .ok_or_else(|| { let names: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect(); anyhow!( "No release asset found for target '{BUILD_TARGET}'. \ Available assets: {}", names.join(", ") ) })?; println!("Asset: {}", asset.name); if !yes { print!("Update to version {latest_version}? [y/N] "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; if !input.trim().eq_ignore_ascii_case("y") { println!("Update cancelled."); return Ok(()); } } // Download the asset to a temporary directory. let tmp_dir = tempfile::Builder::new() .prefix("mdrs-selfupdate-") .tempdir()?; let archive_path = tmp_dir.path().join(&asset.name); println!("Downloading {}...", asset.browser_download_url); let mut download_req = client .get(&asset.browser_download_url) .header(USER_AGENT, format!("mdrs/{current_version}")); if let Ok(token) = env::var("GITEA_TOKEN") { download_req = download_req.header(AUTHORIZATION, format!("Bearer {token}")); } let download_resp = download_req.send().await?; if !download_resp.status().is_success() { bail!("Failed to download asset: HTTP {}", download_resp.status()); } let bytes = download_resp.bytes().await?; // This binary is about to replace the one the user is running, so what arrived has to // be what the release says it is. Transport already proves who served it; the digest // proves the archive was not swapped or truncated on the way. match release .assets .iter() .find(|a| a.name == format!("{}.sha256", asset.name)) { Some(checksum_asset) => { let mut checksum_req = client .get(&checksum_asset.browser_download_url) .header(USER_AGENT, format!("mdrs/{current_version}")); if let Ok(token) = env::var("GITEA_TOKEN") { checksum_req = checksum_req.header(AUTHORIZATION, format!("Bearer {token}")); } let checksum_resp = checksum_req.send().await?; if !checksum_resp.status().is_success() { bail!( "Failed to download checksum: HTTP {}", checksum_resp.status() ); } let expected = parse_sha256_line(&checksum_resp.text().await?) .ok_or_else(|| anyhow!("Checksum asset '{}' is malformed", checksum_asset.name))?; let actual = sha256_hex(&bytes); if actual != expected { bail!( "Checksum mismatch for '{}': expected {}, got {}. The download was not what \ the release publishes, so this binary has not been replaced.", asset.name, expected, actual ); } println!("Checksum verified."); } None => { // Releases built before the workflow published checksums have none to check // against. Say so rather than letting the absence pass for a verified download. eprintln!( "Warning: release {latest_version} publishes no checksum for '{}', \ so the download could not be verified.", asset.name ); } } std::fs::write(&archive_path, &bytes)?; // Extract the binary from the archive. let bin_name = if cfg!(windows) { "mdrs.exe" } else { "mdrs" }; let new_bin = tmp_dir.path().join(bin_name); let name = asset.name.as_str(); if name.ends_with(".tar.gz") || name.ends_with(".tgz") { extract_from_tar_gz(&archive_path, bin_name, &new_bin)?; } else if name.ends_with(".zip") { extract_from_zip(&archive_path, bin_name, &new_bin)?; } else { bail!("Unsupported archive format: {}", asset.name); } // Make the extracted binary executable on Unix. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&new_bin)?.permissions(); perms.set_mode(0o755); std::fs::set_permissions(&new_bin, perms)?; } // Atomically replace the current executable. self_replace::self_replace(&new_bin)?; println!("Successfully updated to version {latest_version}."); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_sha256sum_line_yields_its_digest() { let digest = "a".repeat(64); assert_eq!( parse_sha256_line(&format!( "{digest} mdrs-2.0.1-x86_64-unknown-linux-musl.tar.gz\n" )), Some(digest) ); } #[test] fn a_line_that_is_not_a_digest_is_refused() { for text in ["", "not-a-digest file", "abc123 file"] { assert_eq!(parse_sha256_line(text), None, "{text:?} should be refused"); } } #[test] fn the_digest_matches_what_sha256sum_prints() { // Verified against `printf mdrs | sha256sum`. assert_eq!( sha256_hex(b"mdrs"), "bd9845248df877e411ec8bef10b823bd4c0900a530157eedfdfbfb63ccbde2fa" ); } }