use std::env;
use std::fs;
use std::io::Read;
use std::path::Path;
use std::time::Duration;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::cli::UpgradeArgs;
use crate::json_envelope::{self, JsonEnvelope};
pub(crate) const UPGRADE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct UpgradeReport {
pub current: String,
pub target: String,
pub needs_upgrade: bool,
pub mode: UpgradeMode,
pub installed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub archive_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksums_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_triple: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum UpgradeMode {
Check,
Install,
}
const REPO: &str = "burin-labs/harn";
const RELEASES_BASE: &str = "https://github.com/burin-labs/harn/releases";
const USER_AGENT: &str = concat!("harn-cli/", env!("CARGO_PKG_VERSION"));
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) async fn run(args: UpgradeArgs) -> Result<(), String> {
if args.json {
let exit = tokio::task::spawn_blocking(move || run_blocking_json(args))
.await
.map_err(|error| format!("upgrade task failed: {error}"))?;
if exit != 0 {
std::process::exit(exit);
}
return Ok(());
}
tokio::task::spawn_blocking(move || run_blocking(args))
.await
.map_err(|error| format!("upgrade task failed: {error}"))?
}
fn run_blocking(args: UpgradeArgs) -> Result<(), String> {
let triple = target_triple()?;
let current = env!("CARGO_PKG_VERSION");
let target = match args.version.as_deref() {
Some(v) => normalize_version(v)?,
None => fetch_latest_tag()?,
};
println!("Installed: v{current}");
println!("Target: {target}");
if args.check {
return Ok(());
}
if !args.force && target.trim_start_matches('v') == current {
println!("Already on the latest release.");
return Ok(());
}
let current_exe =
env::current_exe().map_err(|error| format!("failed to resolve current exe: {error}"))?;
let current_exe = current_exe
.canonicalize()
.unwrap_or_else(|_| current_exe.clone());
let install_dir = current_exe
.parent()
.ok_or_else(|| format!("{} has no parent directory", current_exe.display()))?
.to_path_buf();
let archive_name = format!("harn-{triple}.tar.gz");
let archive_url = format!("{RELEASES_BASE}/download/{target}/{archive_name}");
let checksums_url = format!("{RELEASES_BASE}/download/{target}/SHA256SUMS");
let staging = tempfile::tempdir()
.map_err(|error| format!("failed to create staging directory: {error}"))?;
let archive_path = staging.path().join(&archive_name);
println!("Downloading {archive_name}");
download(&archive_url, &archive_path)?;
if args.no_verify {
eprintln!("warning: SHA256 verification skipped (--no-verify)");
} else {
verify_checksum(&checksums_url, &archive_name, &archive_path)?;
}
println!("Extracting");
extract_tarball(&archive_path, staging.path())?;
let staged_binary = staging.path().join(harn_binary_name());
if !staged_binary.exists() {
return Err(format!(
"archive did not contain {} — refusing to upgrade",
harn_binary_name(),
));
}
install_binaries(staging.path(), &install_dir)?;
println!("Upgraded harn to {target}. Re-run your last command to use the new binary.",);
Ok(())
}
fn run_blocking_json(args: UpgradeArgs) -> i32 {
let triple = match target_triple() {
Ok(t) => t,
Err(error) => return emit_upgrade_error("unsupported_target", error),
};
let current = env!("CARGO_PKG_VERSION");
let target = match args.version.as_deref() {
Some(v) => match normalize_version(v) {
Ok(t) => t,
Err(error) => return emit_upgrade_error("invalid_version", error),
},
None => match fetch_latest_tag() {
Ok(t) => t,
Err(error) => return emit_upgrade_error("resolve_failed", error),
},
};
let archive_name = format!("harn-{triple}.tar.gz");
let archive_url = format!("{RELEASES_BASE}/download/{target}/{archive_name}");
let checksums_url = format!("{RELEASES_BASE}/download/{target}/SHA256SUMS");
let needs_upgrade = target.trim_start_matches('v') != current;
let mode = if args.check {
UpgradeMode::Check
} else {
UpgradeMode::Install
};
let mut report = UpgradeReport {
current: current.to_string(),
target: target.clone(),
needs_upgrade,
mode,
installed: false,
archive_url: Some(archive_url.clone()),
checksums_url: Some(checksums_url.clone()),
target_triple: Some(triple.to_string()),
};
if args.check {
emit_upgrade_ok(report);
return 0;
}
if !args.force && !needs_upgrade {
emit_upgrade_ok(report);
return 0;
}
eprintln!("Installed: v{current}");
eprintln!("Target: {target}");
let current_exe = match env::current_exe()
.map_err(|error| format!("failed to resolve current exe: {error}"))
{
Ok(p) => p,
Err(error) => return emit_upgrade_error_with(&report, "resolve_exe_failed", error),
};
let current_exe = current_exe
.canonicalize()
.unwrap_or_else(|_| current_exe.clone());
let install_dir = match current_exe
.parent()
.ok_or_else(|| format!("{} has no parent directory", current_exe.display()))
{
Ok(p) => p.to_path_buf(),
Err(error) => return emit_upgrade_error_with(&report, "resolve_exe_failed", error),
};
let staging = match tempfile::tempdir()
.map_err(|error| format!("failed to create staging directory: {error}"))
{
Ok(s) => s,
Err(error) => return emit_upgrade_error_with(&report, "staging_failed", error),
};
let archive_path = staging.path().join(&archive_name);
eprintln!("Downloading {archive_name}");
if let Err(error) = download(&archive_url, &archive_path) {
return emit_upgrade_error_with(&report, "download_failed", error);
}
if args.no_verify {
eprintln!("warning: SHA256 verification skipped (--no-verify)");
} else if let Err(error) = verify_checksum(&checksums_url, &archive_name, &archive_path) {
return emit_upgrade_error_with(&report, "checksum_failed", error);
}
eprintln!("Extracting");
if let Err(error) = extract_tarball(&archive_path, staging.path()) {
return emit_upgrade_error_with(&report, "extract_failed", error);
}
let staged_binary = staging.path().join(harn_binary_name());
if !staged_binary.exists() {
return emit_upgrade_error_with(
&report,
"archive_missing_binary",
format!(
"archive did not contain {} — refusing to upgrade",
harn_binary_name()
),
);
}
if let Err(error) = install_binaries(staging.path(), &install_dir) {
return emit_upgrade_error_with(&report, "install_failed", error);
}
report.installed = true;
emit_upgrade_ok(report);
0
}
fn emit_upgrade_ok(report: UpgradeReport) {
let envelope = JsonEnvelope::ok(UPGRADE_SCHEMA_VERSION, report);
println!("{}", json_envelope::to_string_pretty(&envelope));
}
fn emit_upgrade_error(code: &str, message: String) -> i32 {
let envelope: JsonEnvelope<UpgradeReport> =
JsonEnvelope::err(UPGRADE_SCHEMA_VERSION, code, message);
println!("{}", json_envelope::to_string_pretty(&envelope));
1
}
fn emit_upgrade_error_with(report: &UpgradeReport, code: &str, message: String) -> i32 {
let envelope = JsonEnvelope {
schema_version: UPGRADE_SCHEMA_VERSION,
ok: false,
data: Some(report.clone()),
error: Some(json_envelope::JsonError {
code: code.to_string(),
message,
details: serde_json::Value::Null,
}),
warnings: Vec::new(),
};
println!("{}", json_envelope::to_string_pretty(&envelope));
1
}
fn target_triple() -> Result<&'static str, String> {
let triple = if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
"aarch64-unknown-linux-gnu"
} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
return Err(
"`harn upgrade` is not implemented for Windows yet; download the zip from \
https://github.com/burin-labs/harn/releases and replace harn.exe manually"
.to_string(),
);
} else {
return Err("self-update is not supported on this target".to_string());
};
Ok(triple)
}
fn harn_binary_name() -> &'static str {
if cfg!(target_os = "windows") {
"harn.exe"
} else {
"harn"
}
}
fn extra_binary_names() -> &'static [&'static str] {
if cfg!(target_os = "windows") {
&["harn-dap.exe", "harn-lsp.exe"]
} else {
&["harn-dap", "harn-lsp"]
}
}
fn normalize_version(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("--version cannot be empty".to_string());
}
let normalized = if trimmed.starts_with('v') {
trimmed.to_string()
} else {
format!("v{trimmed}")
};
let rest = normalized.trim_start_matches('v');
let parts: Vec<&str> = rest.split('.').collect();
if parts.len() != 3
|| parts
.iter()
.any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
{
return Err(format!("invalid version: {input} (expected vX.Y.Z)"));
}
Ok(normalized)
}
fn http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|error| format!("failed to build HTTP client: {error}"))
}
fn fetch_latest_tag() -> Result<String, String> {
let client = http_client()?;
let response = client
.get(format!(
"https://api.github.com/repos/{REPO}/releases/latest"
))
.header("Accept", "application/vnd.github+json")
.send()
.map_err(|error| format!("failed to query latest release: {error}"))?;
if !response.status().is_success() {
return Err(format!(
"GitHub API returned status {} when resolving latest release",
response.status()
));
}
let body: serde_json::Value = response
.json()
.map_err(|error| format!("failed to parse latest release response: {error}"))?;
let tag = body
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| "GitHub API response did not include a tag_name".to_string())?;
Ok(tag.to_string())
}
fn download(url: &str, dest: &Path) -> Result<(), String> {
let client = http_client()?;
let mut response = client
.get(url)
.send()
.map_err(|error| format!("failed to download {url}: {error}"))?;
if !response.status().is_success() {
return Err(format!(
"download {url} returned status {}",
response.status()
));
}
let mut file = fs::File::create(dest)
.map_err(|error| format!("failed to create {}: {error}", dest.display()))?;
response
.copy_to(&mut file)
.map_err(|error| format!("failed to write {}: {error}", dest.display()))?;
Ok(())
}
fn verify_checksum(checksums_url: &str, asset: &str, path: &Path) -> Result<(), String> {
let client = http_client()?;
let response = client
.get(checksums_url)
.send()
.map_err(|error| format!("failed to fetch SHA256SUMS: {error}"))?;
if !response.status().is_success() {
eprintln!(
"warning: no SHA256SUMS published for this release (status {}); skipping verification",
response.status()
);
return Ok(());
}
let manifest = response
.text()
.map_err(|error| format!("failed to read SHA256SUMS body: {error}"))?;
let expected = find_expected_sha(&manifest, asset)
.ok_or_else(|| format!("SHA256SUMS does not include an entry for {asset}"))?;
let mut file = fs::File::open(path)
.map_err(|error| format!("failed to open {}: {error}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file
.read(&mut buf)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let actual = hex_lower(&hasher.finalize());
if actual != expected {
return Err(format!(
"SHA256 mismatch for {asset}: expected {expected}, got {actual}"
));
}
println!("Verified SHA256 ({asset})");
Ok(())
}
fn find_expected_sha(manifest: &str, asset: &str) -> Option<String> {
for line in manifest.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.splitn(2, char::is_whitespace);
let digest = parts.next()?.trim();
let name = parts.next()?.trim().trim_start_matches('*');
let basename = Path::new(name)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(name);
if basename == asset {
return Some(digest.to_ascii_lowercase());
}
}
None
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn extract_tarball(archive: &Path, dest: &Path) -> Result<(), String> {
let file = fs::File::open(archive)
.map_err(|error| format!("failed to open archive {}: {error}", archive.display()))?;
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
tar.unpack(dest)
.map_err(|error| format!("failed to extract archive: {error}"))?;
Ok(())
}
fn install_binaries(staging: &Path, install_dir: &Path) -> Result<(), String> {
let mut names: Vec<&str> = vec![harn_binary_name()];
names.extend_from_slice(extra_binary_names());
for name in names {
let src = staging.join(name);
if !src.exists() {
if name == harn_binary_name() {
return Err(format!("staging directory missing {name}"));
}
continue;
}
let dest = install_dir.join(name);
atomic_replace(&src, &dest)?;
}
Ok(())
}
fn atomic_replace(src: &Path, dest: &Path) -> Result<(), String> {
let parent = dest
.parent()
.ok_or_else(|| format!("{} has no parent directory", dest.display()))?;
let dest_basename = dest
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| format!("{} has no file name", dest.display()))?;
let temp_in_dest = parent.join(format!(
".{dest_basename}.harn-upgrade-{pid}-{counter}",
pid = std::process::id(),
counter = next_upgrade_counter(),
));
if let Err(error) = fs::copy(src, &temp_in_dest) {
return Err(format!(
"failed to stage {} -> {}: {error}",
src.display(),
temp_in_dest.display(),
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
if let Err(error) = fs::set_permissions(&temp_in_dest, perms) {
let _ = fs::remove_file(&temp_in_dest);
return Err(format!("failed to chmod staged binary: {error}"));
}
}
if let Err(error) = fs::rename(&temp_in_dest, dest) {
let _ = fs::remove_file(&temp_in_dest);
return Err(format!("failed to replace {}: {error}", dest.display(),));
}
Ok(())
}
fn next_upgrade_counter() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
COUNTER.fetch_add(1, Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_version_accepts_v_prefix() {
assert_eq!(normalize_version("v0.8.19").unwrap(), "v0.8.19");
}
#[test]
fn normalize_version_adds_v_prefix() {
assert_eq!(normalize_version("0.8.19").unwrap(), "v0.8.19");
}
#[test]
fn normalize_version_rejects_garbage() {
assert!(normalize_version("not-a-version").is_err());
assert!(normalize_version("v0.8").is_err());
assert!(normalize_version("").is_err());
assert!(normalize_version("v0.8.x").is_err());
}
#[test]
fn find_expected_sha_parses_coreutils_format() {
let manifest = "\
deadbeef00000000000000000000000000000000000000000000000000000000 harn-x86_64-apple-darwin.tar.gz
cafef00d00000000000000000000000000000000000000000000000000000000 harn-aarch64-apple-darwin.tar.gz
";
assert_eq!(
find_expected_sha(manifest, "harn-aarch64-apple-darwin.tar.gz").as_deref(),
Some("cafef00d00000000000000000000000000000000000000000000000000000000"),
);
}
#[test]
fn find_expected_sha_tolerates_binary_mode_marker() {
let manifest =
"deadbeef00000000000000000000000000000000000000000000000000000000 *harn-x86_64-pc-windows-msvc.zip\n";
assert_eq!(
find_expected_sha(manifest, "harn-x86_64-pc-windows-msvc.zip").as_deref(),
Some("deadbeef00000000000000000000000000000000000000000000000000000000"),
);
}
#[test]
fn find_expected_sha_misses_unknown_asset() {
let manifest = "abc some-other.tar.gz\n";
assert!(find_expected_sha(manifest, "harn-x86_64-apple-darwin.tar.gz").is_none());
}
}