Files
mdrs-client-rust/src/commands/selfupdate.rs
T
Yoshihiro OKUMURA c24a285cf5 fix(selfupdate): verify the archive before replacing the binary
`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.
2026-09-04 16:31:02 +09:00

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"
);
}
}