`mdrs selfupdate` replaced the running binary with whatever the release endpoint returned, checking only that the transport succeeded. Nothing proved the archive was the one the release publishes. - Compare the downloaded archive against the release's `.sha256` asset and abort the update on a mismatch. - Report a release that publishes no checksum as unverified, rather than letting its absence pass for a verified download. - Exclude `.sha256` assets when matching the archive for the build target: those assets carry the target name too. - Write and upload a checksum beside every archive, from the Gitea release workflow and the three local build scripts.
319 lines
10 KiB
Rust
319 lines
10 KiB
Rust
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<GiteaAsset>,
|
|
}
|
|
|
|
#[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<u64> {
|
|
s.trim_start_matches('v')
|
|
.split('.')
|
|
.map(|p| p.parse::<u64>().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<String> {
|
|
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<GiteaRelease> = 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"
|
|
);
|
|
}
|
|
}
|