use crate::config::{Config, OutputFormat};
use crate::render::color;
use crate::VERSION;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use super::{fail_icon, success_icon};
const RELEASES_URL: &str =
"https://api.github.com/repos/QubeTX/qube-network-diagnostics/releases/latest";
#[cfg(not(windows))]
const SHELL_INSTALLER: &str =
"https://github.com/QubeTX/qube-network-diagnostics/releases/latest/download/nd300-installer.sh";
#[cfg(windows)]
const PS_INSTALLER: &str =
"https://github.com/QubeTX/qube-network-diagnostics/releases/latest/download/nd300-installer.ps1";
#[cfg(windows)]
const MSI_GLOBAL_URL: &str = "https://github.com/QubeTX/qube-network-diagnostics/releases/latest/download/nd300-x86_64-pc-windows-msvc.msi";
#[cfg(windows)]
const MSI_CORPORATE_URL: &str = "https://github.com/QubeTX/qube-network-diagnostics/releases/latest/download/nd300-x86_64-pc-windows-msvc-corporate.msi";
#[cfg(windows)]
const EXE_GLOBAL_URL: &str = "https://github.com/QubeTX/qube-network-diagnostics/releases/latest/download/nd300-x86_64-pc-windows-msvc-setup.exe";
#[cfg(windows)]
const EXE_CORPORATE_URL: &str = "https://github.com/QubeTX/qube-network-diagnostics/releases/latest/download/nd300-x86_64-pc-windows-msvc-corporate-setup.exe";
const MANUAL_INSTALL_URL: &str = "https://github.com/QubeTX/qube-network-diagnostics#install";
#[cfg_attr(not(windows), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdateStrategy {
Cargo,
InstallerCurl,
InstallerWget,
InstallerPowerShell,
InstallerPwsh,
MsiGlobal,
MsiCorporate,
ExeGlobal,
ExeCorporate,
}
impl UpdateStrategy {
fn label(self) -> &'static str {
match self {
UpdateStrategy::Cargo => "cargo install",
UpdateStrategy::InstallerCurl => "curl shell installer",
UpdateStrategy::InstallerWget => "wget shell installer",
UpdateStrategy::InstallerPowerShell => "PowerShell installer",
UpdateStrategy::InstallerPwsh => "pwsh installer",
UpdateStrategy::MsiGlobal => "Global MSI installer",
UpdateStrategy::MsiCorporate => "Corporate MSI installer",
UpdateStrategy::ExeGlobal => "Global EXE installer",
UpdateStrategy::ExeCorporate => "Corporate EXE installer",
}
}
fn json_id(self) -> &'static str {
match self {
UpdateStrategy::Cargo => "cargo",
UpdateStrategy::InstallerCurl => "installer_curl",
UpdateStrategy::InstallerWget => "installer_wget",
UpdateStrategy::InstallerPowerShell => "installer_powershell",
UpdateStrategy::InstallerPwsh => "installer_pwsh",
UpdateStrategy::MsiGlobal => "msi_global",
UpdateStrategy::MsiCorporate => "msi_corporate",
UpdateStrategy::ExeGlobal => "exe_global",
UpdateStrategy::ExeCorporate => "exe_corporate",
}
}
fn json_method(self) -> &'static str {
if matches!(self, UpdateStrategy::Cargo) {
"cargo"
} else {
"installer"
}
}
}
#[cfg(windows)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallOrigin {
MsiGlobal,
MsiCorporate,
ExeGlobal,
ExeCorporate,
CargoOrInstaller,
Unknown,
}
#[cfg(windows)]
impl InstallOrigin {
fn json_id(self) -> &'static str {
match self {
InstallOrigin::MsiGlobal => "msi-global",
InstallOrigin::MsiCorporate => "msi-corporate",
InstallOrigin::ExeGlobal => "exe-global",
InstallOrigin::ExeCorporate => "exe-corporate",
InstallOrigin::CargoOrInstaller => "cargo-or-installer",
InstallOrigin::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetOs {
Unix,
Windows,
}
#[derive(Debug)]
enum StrategyError {
Preflight(String),
Runtime(String),
Fatal(String),
}
#[derive(Debug)]
enum AttemptKind {
Skipped,
Failed,
}
#[derive(Debug)]
struct AttemptRecord {
strategy: UpdateStrategy,
kind: AttemptKind,
message: String,
}
#[derive(Debug)]
struct UpdateFailure {
attempts: Vec<AttemptRecord>,
}
pub async fn run(config: &Config) -> i32 {
if config.format == OutputFormat::Json {
return run_json(config).await;
}
println!();
println!(" {} Checking for updates...", color::cyan("*", config));
let latest = match fetch_latest_version().await {
Ok(v) => v,
Err(e) => {
println!(
" {} {}",
color::red(fail_icon(config), config),
color::red(&format!("Failed to check for updates: {}", e), config),
);
return 2;
}
};
let current = VERSION.to_string();
if !is_newer(¤t, &latest) {
println!(
" {} {}",
color::green(success_icon(config), config),
color::green(
&format!("Already on the latest version (v{})", current),
config
),
);
return 0;
}
println!(
" {} Update available: v{} {} v{}",
color::cyan("*", config),
current,
color::cyan("->", config),
latest,
);
let strategies = build_strategy_list();
let primary = strategies.first().copied();
if let Some(s) = primary {
println!(
" {} Updating via {}...",
color::cyan("*", config),
s.label()
);
}
println!();
match execute_update(&latest, &strategies) {
Ok(used) => {
println!();
println!(
" {} {}",
color::green(success_icon(config), config),
color::green(
&format!("Updated to v{} via {}", latest, used.label()),
config
),
);
0
}
Err(failure) => {
println!();
println!(
" {} {}",
color::red(fail_icon(config), config),
color::red("Update failed. Strategies attempted:", config),
);
for record in &failure.attempts {
let kind = match record.kind {
AttemptKind::Skipped => "skipped",
AttemptKind::Failed => "failed",
};
println!(
" · {} — {}: {}",
record.strategy.label(),
kind,
record.message
);
}
println!();
println!(" To update manually, see: {}", MANUAL_INSTALL_URL);
2
}
}
}
async fn run_json(_config: &Config) -> i32 {
let latest = match fetch_latest_version().await {
Ok(v) => v,
Err(e) => {
let mut output = serde_json::json!({
"action": "update",
"success": false,
"message": format!("Failed to check for updates: {}", e),
"current_version": VERSION,
});
inject_install_origin(&mut output);
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
);
return 2;
}
};
let current = VERSION.to_string();
let update_available = is_newer(¤t, &latest);
if !update_available {
let mut output = serde_json::json!({
"action": "update",
"success": true,
"message": "Already on the latest version",
"current_version": current,
"latest_version": latest,
"update_available": false,
});
inject_install_origin(&mut output);
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
);
return 0;
}
let strategies = build_strategy_list();
match execute_update(&latest, &strategies) {
Ok(used) => {
let mut output = serde_json::json!({
"action": "update",
"success": true,
"message": format!("Updated from v{} to v{}", current, latest),
"current_version": current,
"latest_version": latest,
"update_available": true,
"method": used.json_method(),
"strategy": used.json_id(),
});
inject_install_origin(&mut output);
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
);
0
}
Err(failure) => {
let attempts: Vec<serde_json::Value> = failure
.attempts
.iter()
.map(|r| {
serde_json::json!({
"strategy": r.strategy.json_id(),
"result": match r.kind {
AttemptKind::Skipped => "skipped",
AttemptKind::Failed => "failed",
},
"message": r.message,
})
})
.collect();
let mut output = serde_json::json!({
"action": "update",
"success": false,
"message": "Update failed; see attempts",
"current_version": current,
"latest_version": latest,
"update_available": true,
"attempts": attempts,
});
inject_install_origin(&mut output);
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
);
2
}
}
}
#[cfg(windows)]
fn inject_install_origin(payload: &mut serde_json::Value) {
if let Some(obj) = payload.as_object_mut() {
obj.insert(
"install_origin".to_string(),
serde_json::Value::String(detect_install_origin().json_id().to_string()),
);
}
}
#[cfg(not(windows))]
fn inject_install_origin(_payload: &mut serde_json::Value) {
}
fn http_status_message(code: u16, ratelimit_remaining: Option<&str>) -> String {
if code == 403 && ratelimit_remaining == Some("0") {
"GitHub API rate limit exceeded (unauthenticated requests are capped at 60/hour per IP). Wait for the limit to reset and try again, or update manually.".to_string()
} else {
format!(
"GitHub API returned HTTP {} when checking for updates",
code
)
}
}
async fn fetch_latest_version() -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("HTTP client error: {}", e))?;
let resp = client
.get(RELEASES_URL)
.header("User-Agent", format!("nd300/{}", VERSION))
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !resp.status().is_success() {
let code = resp.status().as_u16();
let remaining = resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
return Err(http_status_message(code, remaining.as_deref()));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let tag = body["tag_name"]
.as_str()
.ok_or("Missing tag_name in response")?;
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
fn strip_prerelease_metadata(v: &str) -> &str {
match v.find(|c: char| !c.is_ascii_digit() && c != '.') {
Some(idx) => &v[..idx],
None => v,
}
}
fn is_newer(current: &str, latest: &str) -> bool {
let current_stripped = strip_prerelease_metadata(current);
let latest_stripped = strip_prerelease_metadata(latest);
let parse =
|v: &str| -> Vec<u64> { v.split('.').filter_map(|s| s.parse::<u64>().ok()).collect() };
let c = parse(current_stripped);
let l = parse(latest_stripped);
let len = c.len().max(l.len());
for i in 0..len {
let cv = c.get(i).copied().unwrap_or(0);
let lv = l.get(i).copied().unwrap_or(0);
if lv > cv {
return true;
}
if lv < cv {
return false;
}
}
let current_has_suffix = current.len() != current_stripped.len();
let latest_has_suffix = latest.len() != latest_stripped.len();
current_has_suffix && !latest_has_suffix
}
fn order_strategies(cargo_invokable: bool, os: TargetOs) -> Vec<UpdateStrategy> {
let mut list = Vec::new();
if cargo_invokable {
list.push(UpdateStrategy::Cargo);
}
match os {
TargetOs::Unix => {
list.push(UpdateStrategy::InstallerCurl);
list.push(UpdateStrategy::InstallerWget);
}
TargetOs::Windows => {
list.push(UpdateStrategy::InstallerPowerShell);
list.push(UpdateStrategy::InstallerPwsh);
}
}
list
}
fn current_target_os() -> TargetOs {
if cfg!(windows) {
TargetOs::Windows
} else {
TargetOs::Unix
}
}
fn cargo_invokable() -> bool {
tool_exists("cargo")
}
fn tool_exists(tool: &str) -> bool {
Command::new(tool)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn build_strategy_list() -> Vec<UpdateStrategy> {
#[cfg(windows)]
{
match detect_install_origin() {
InstallOrigin::MsiGlobal => return vec![UpdateStrategy::MsiGlobal],
InstallOrigin::MsiCorporate => return vec![UpdateStrategy::MsiCorporate],
InstallOrigin::ExeGlobal => return vec![UpdateStrategy::ExeGlobal],
InstallOrigin::ExeCorporate => return vec![UpdateStrategy::ExeCorporate],
InstallOrigin::CargoOrInstaller | InstallOrigin::Unknown => {
}
}
}
order_strategies(cargo_invokable(), current_target_os())
}
fn execute_update(
latest: &str,
strategies: &[UpdateStrategy],
) -> Result<UpdateStrategy, UpdateFailure> {
let mut attempts = Vec::new();
for &strategy in strategies {
match try_strategy(strategy, latest) {
Ok(()) => return Ok(strategy),
Err(StrategyError::Preflight(msg)) => {
eprintln!(" · skipped {}: {}", strategy.label(), msg);
attempts.push(AttemptRecord {
strategy,
kind: AttemptKind::Skipped,
message: msg,
});
}
Err(StrategyError::Runtime(msg)) => {
eprintln!(" · {} failed: {}", strategy.label(), msg);
attempts.push(AttemptRecord {
strategy,
kind: AttemptKind::Failed,
message: msg,
});
}
Err(StrategyError::Fatal(msg)) => {
eprintln!(" · {} failed: {}", strategy.label(), msg);
attempts.push(AttemptRecord {
strategy,
kind: AttemptKind::Failed,
message: msg,
});
return Err(UpdateFailure { attempts });
}
}
}
Err(UpdateFailure { attempts })
}
fn try_strategy(strategy: UpdateStrategy, latest: &str) -> Result<(), StrategyError> {
match strategy {
UpdateStrategy::Cargo => try_cargo_install(latest),
UpdateStrategy::InstallerCurl => try_installer_curl(),
UpdateStrategy::InstallerWget => try_installer_wget(),
UpdateStrategy::InstallerPowerShell => try_installer_powershell("powershell"),
UpdateStrategy::InstallerPwsh => try_installer_powershell("pwsh"),
UpdateStrategy::MsiGlobal => try_msi_install(msi_global_url(), latest),
UpdateStrategy::MsiCorporate => try_msi_install(msi_corporate_url(), latest),
UpdateStrategy::ExeGlobal => try_exe_install(exe_global_url(), latest),
UpdateStrategy::ExeCorporate => try_exe_install(exe_corporate_url(), latest),
}
}
#[cfg(windows)]
fn msi_global_url() -> &'static str {
MSI_GLOBAL_URL
}
#[cfg(windows)]
fn msi_corporate_url() -> &'static str {
MSI_CORPORATE_URL
}
#[cfg(windows)]
fn exe_global_url() -> &'static str {
EXE_GLOBAL_URL
}
#[cfg(windows)]
fn exe_corporate_url() -> &'static str {
EXE_CORPORATE_URL
}
#[cfg(not(windows))]
fn msi_global_url() -> &'static str {
""
}
#[cfg(not(windows))]
fn msi_corporate_url() -> &'static str {
""
}
#[cfg(not(windows))]
fn exe_global_url() -> &'static str {
""
}
#[cfg(not(windows))]
fn exe_corporate_url() -> &'static str {
""
}
fn try_cargo_install(latest: &str) -> Result<(), StrategyError> {
rustup_update_stable_best_effort();
match run_command_capture("cargo", &["install", "nd300", "--force"]) {
Ok(()) => {
cleanup_shadowing_current_install_after_cargo_success()?;
verify_cargo_post_install(latest)
}
Err(StrategyError::Runtime(msg))
if cargo_failure_suggests_existing_binary_collision(&msg) =>
{
cleanup_current_install_for_cargo_retry()?;
run_command_capture("cargo", &["install", "nd300", "--force"])?;
verify_cargo_post_install(latest)
}
other => other,
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ShadowCleanupDecision {
Removed,
Scheduled,
NotRemoved,
}
pub(crate) fn classify_shadow_cleanup(
report: &super::uninstall::CleanupReport,
) -> ShadowCleanupDecision {
if report.binary_removed {
ShadowCleanupDecision::Removed
} else if report.binary_removal_scheduled {
ShadowCleanupDecision::Scheduled
} else {
ShadowCleanupDecision::NotRemoved
}
}
fn cleanup_shadowing_current_install_after_cargo_success() -> Result<(), StrategyError> {
let Some(current_exe) = current_exe_real_path() else {
return Ok(());
};
let Some(cargo_bin) = cargo_bin_dir() else {
return Ok(());
};
if !current_install_shadows_cargo_install(¤t_exe, &cargo_bin) {
return Ok(());
}
let report = super::uninstall::uninstall_path(¤t_exe);
match classify_shadow_cleanup(&report) {
ShadowCleanupDecision::Removed => {
eprintln!(
" · removed old non-cargo install at {} after cargo install",
current_exe.display()
);
Ok(())
}
ShadowCleanupDecision::Scheduled => {
eprintln!(
" · WARNING: cargo install succeeded, but the old ND300 install at {} is still running and could not be removed in place.",
current_exe.display()
);
eprintln!(
" It is scheduled for deletion when this process exits. The new cargo install in {} will take precedence in a NEW shell.",
cargo_bin.display()
);
eprintln!(
" If `nd300 --version` still reports the old version in a new shell, remove the old path manually: {}",
current_exe.display()
);
Ok(())
}
ShadowCleanupDecision::NotRemoved => Err(StrategyError::Fatal(format!(
"cargo install succeeded, but the old ND300 install at {} could not be removed: {}",
current_exe.display(),
cleanup_notes(&report)
))),
}
}
fn cleanup_current_install_for_cargo_retry() -> Result<(), StrategyError> {
let Some(current_exe) = current_exe_real_path() else {
return Ok(());
};
let report = super::uninstall::uninstall_path(¤t_exe);
match classify_shadow_cleanup(&report) {
ShadowCleanupDecision::Removed => {
eprintln!(
" · removed existing ND300 install at {} before retrying cargo install",
current_exe.display()
);
Ok(())
}
ShadowCleanupDecision::Scheduled => Err(StrategyError::Fatal(format!(
"cargo reported an existing nd300/speedqx binary, and removal of the current install at {} was only scheduled for process exit. \
A cargo retry needs the file gone now — close this nd300 process and re-run `nd300 update`.",
current_exe.display()
))),
ShadowCleanupDecision::NotRemoved => Err(StrategyError::Fatal(format!(
"cargo reported an existing nd300/speedqx binary, but the current install at {} could not be removed: {}",
current_exe.display(),
cleanup_notes(&report)
))),
}
}
fn cleanup_notes(report: &super::uninstall::CleanupReport) -> String {
if report.notes.is_empty() {
"no additional details".to_string()
} else {
report.notes.join("; ")
}
}
pub(crate) fn current_exe_real_path() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
Some(exe.canonicalize().unwrap_or(exe))
}
pub(crate) fn cargo_bin_dir() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("CARGO_HOME") {
return Some(PathBuf::from(home).join("bin"));
}
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".cargo").join("bin"))
}
#[cfg(not(windows))]
{
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo").join("bin"))
}
}
pub(crate) fn current_install_shadows_cargo_install(
current_exe: &Path,
cargo_bin_dir: &Path,
) -> bool {
if current_exe_looks_like_local_build(current_exe) {
return false;
}
let Some(current_dir) = current_exe.parent() else {
return false;
};
!same_path(current_dir, cargo_bin_dir)
}
fn current_exe_looks_like_local_build(current_exe: &Path) -> bool {
current_exe
.parent()
.and_then(|dir| dir.file_name())
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, "debug" | "release"))
&& current_exe
.parent()
.and_then(|dir| dir.parent())
.and_then(|dir| dir.file_name())
.and_then(|name| name.to_str())
== Some("target")
}
pub(crate) fn same_path(left: &Path, right: &Path) -> bool {
let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
#[cfg(windows)]
{
left.to_string_lossy()
.trim_end_matches(['\\', '/'])
.eq_ignore_ascii_case(right.to_string_lossy().trim_end_matches(['\\', '/']))
}
#[cfg(not(windows))]
{
left == right
}
}
fn cargo_failure_suggests_existing_binary_collision(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("already exists") && (lower.contains("nd300") || lower.contains("speedqx"))
}
fn rustup_update_stable_best_effort() {
if !tool_exists("rustup") {
return;
}
println!(" Updating Rust toolchain (rustup update stable)…");
let _ = Command::new("rustup").args(["update", "stable"]).status();
}
fn run_command_status(launcher: &str, args: &[&str]) -> Result<(), StrategyError> {
let res = Command::new(launcher).args(args).status();
match res {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StrategyError::Preflight(
format!("{} not on PATH", launcher),
)),
Err(e) => Err(StrategyError::Preflight(format!(
"Failed to spawn {}: {}",
launcher, e
))),
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(StrategyError::Runtime(format!(
"{} exited with code {}",
launcher,
s.code().unwrap_or(-1)
))),
}
}
fn run_command_capture(launcher: &str, args: &[&str]) -> Result<(), StrategyError> {
let res = Command::new(launcher).args(args).output();
match res {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StrategyError::Preflight(
format!("{} not on PATH", launcher),
)),
Err(e) => Err(StrategyError::Preflight(format!(
"Failed to spawn {}: {}",
launcher, e
))),
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(StrategyError::Runtime(format!(
"{} exited with code {}; {}",
launcher,
output.status.code().unwrap_or(-1),
summarize_output(&output)
))),
}
}
fn summarize_output(output: &Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}{}", stdout, stderr).trim().to_string();
if combined.is_empty() {
return "no output".to_string();
}
const MAX_CHARS: usize = 4000;
let chars: Vec<char> = combined.chars().collect();
if chars.len() <= MAX_CHARS {
combined
} else {
chars[chars.len() - MAX_CHARS..].iter().collect()
}
}
#[cfg(unix)]
fn try_installer_curl() -> Result<(), StrategyError> {
if !tool_exists("curl") {
return Err(StrategyError::Preflight("curl not on PATH".into()));
}
let script = format!(
"set -e; set -o pipefail; curl --proto '=https' --tlsv1.2 -fLsS {} | sh",
SHELL_INSTALLER
);
run_command_status("sh", &["-c", &script])
}
#[cfg(not(unix))]
fn try_installer_curl() -> Result<(), StrategyError> {
Err(StrategyError::Preflight(
"curl installer is Unix-only".into(),
))
}
#[cfg(unix)]
fn try_installer_wget() -> Result<(), StrategyError> {
if !tool_exists("wget") {
return Err(StrategyError::Preflight("wget not on PATH".into()));
}
let script = format!(
"set -e; set -o pipefail; wget -qO- {} | sh",
SHELL_INSTALLER
);
run_command_status("sh", &["-c", &script])
}
#[cfg(not(unix))]
fn try_installer_wget() -> Result<(), StrategyError> {
Err(StrategyError::Preflight(
"wget installer is Unix-only".into(),
))
}
#[cfg(windows)]
fn try_installer_powershell(launcher: &str) -> Result<(), StrategyError> {
let script = format!("$ErrorActionPreference='Stop'; irm {} | iex", PS_INSTALLER);
run_command_status(
launcher,
&[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
&script,
],
)
}
#[cfg(not(windows))]
fn try_installer_powershell(_launcher: &str) -> Result<(), StrategyError> {
Err(StrategyError::Preflight(
"PowerShell installer is Windows-only".into(),
))
}
#[cfg(windows)]
const MSI_EXIT_REBOOT_REQUIRED: i32 = 3010;
#[cfg(windows)]
fn block_on<F>(fut: F) -> F::Output
where
F: std::future::Future + Send,
F::Output: Send,
{
std::thread::scope(|scope| {
scope
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build current-thread runtime for installer download")
.block_on(fut)
})
.join()
.expect("installer download thread panicked")
})
}
#[cfg(windows)]
fn download_to_file(url: &str, path: &std::path::Path) -> Result<(), String> {
block_on(async move {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| format!("HTTP client error: {}", e))?;
let resp = client
.get(url)
.header("User-Agent", format!("nd300/{}", VERSION))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Download returned HTTP {}", resp.status()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
std::fs::write(path, &bytes)
.map_err(|e| format!("Failed to write temp file {}: {}", path.display(), e))
})
}
#[cfg(windows)]
fn fetch_sha256_sidecar(url: &str) -> Result<String, String> {
let sidecar_url = format!("{}.sha256", url);
block_on(async move {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("HTTP client error: {}", e))?;
let resp = client
.get(&sidecar_url)
.header("User-Agent", format!("nd300/{}", VERSION))
.send()
.await
.map_err(|e| format!("Sidecar request failed ({}): {}", sidecar_url, e))?;
if !resp.status().is_success() {
return Err(format!(
"Sidecar fetch returned HTTP {} ({})",
resp.status(),
sidecar_url
));
}
resp.text()
.await
.map_err(|e| format!("Failed to read sidecar body: {}", e))
})
}
#[cfg(windows)]
fn parse_sha256_sidecar(content: &str) -> Option<String> {
content
.split_whitespace()
.next()
.filter(|s| s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()))
.map(|s| s.to_lowercase())
}
#[cfg(windows)]
fn compute_sha256(path: &std::path::Path) -> Result<String, String> {
use sha2::{Digest, Sha256};
let mut file = std::fs::File::open(path)
.map_err(|e| format!("Failed to open {}: {}", path.display(), e))?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher).map_err(|e| format!("Failed to hash: {}", e))?;
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(windows)]
fn verify_checksum(installer_path: &std::path::Path, installer_url: &str) -> Result<(), String> {
println!(" Verifying SHA256 checksum...");
let sidecar_content = fetch_sha256_sidecar(installer_url)?;
let expected = parse_sha256_sidecar(&sidecar_content).ok_or_else(|| {
format!(
"Malformed .sha256 sidecar from {}.sha256: {:?}",
installer_url, sidecar_content
)
})?;
let actual = compute_sha256(installer_path)?;
checksum_verdict(&actual, &expected)
}
#[cfg(any(windows, test))]
fn checksum_verdict(actual: &str, expected: &str) -> Result<(), String> {
if actual.eq_ignore_ascii_case(expected) {
Ok(())
} else {
Err(format!(
"SHA256 mismatch — refusing to run installer.\n Expected: {}\n Got: {}\n This usually indicates a corrupted download or a network MITM.",
expected, actual
))
}
}
fn post_install_version_ok(installed: &str, expected: &str) -> bool {
let installed_stripped = strip_prerelease_metadata(installed);
let expected_stripped = strip_prerelease_metadata(expected);
!installed_stripped.is_empty() && installed_stripped == expected_stripped
}
fn reexec_installed_version() -> Option<String> {
let exe = std::env::current_exe().ok()?;
let output = Command::new(&exe).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let v = String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.last()
.unwrap_or("")
.trim()
.to_string();
if v.is_empty() {
None
} else {
Some(v)
}
}
fn verify_cargo_post_install(expected: &str) -> Result<(), StrategyError> {
match reexec_installed_version() {
Some(installed) if post_install_version_ok(&installed, expected) => Ok(()),
Some(installed) => Err(StrategyError::Runtime(format!(
"cargo install reported success but `nd300 --version` still reports v{installed} (expected v{expected}). crates.io may not have v{expected} yet, or another nd300 is earlier on your PATH — falling through to the prebuilt installer."
))),
None => Err(StrategyError::Runtime(
"cargo install reported success but `nd300 --version` could not be run to confirm the new version — falling through to the prebuilt installer.".to_string(),
)),
}
}
#[cfg(windows)]
fn verify_post_install(expected: &str) -> Result<(), String> {
let installed = reexec_installed_version()
.ok_or_else(|| "Failed to run `nd300 --version` to confirm the install".to_string())?;
if post_install_version_ok(&installed, expected) {
Ok(())
} else {
Err(format!(
"Installer exited successfully but `nd300 --version` still reports v{} (expected v{}). The installed binary may be locked by another process — close other nd300/speedqx windows / shells and re-run, or reboot to let Windows finish a deferred file replace.",
installed, expected
))
}
}
#[cfg(windows)]
fn try_msi_install(url: &str, latest: &str) -> Result<(), StrategyError> {
let temp_path = std::env::temp_dir().join(format!("nd300-update-{}.msi", latest));
let result = (|| -> Result<(), StrategyError> {
println!(" Downloading MSI installer...");
download_to_file(url, &temp_path)
.map_err(|e| StrategyError::Runtime(format!("Download failed: {}", e)))?;
verify_checksum(&temp_path, url).map_err(StrategyError::Runtime)?;
println!(" Launching Windows Installer...");
let status = Command::new("msiexec")
.args(["/i", &temp_path.to_string_lossy(), "/passive", "/norestart"])
.status()
.map_err(|e| StrategyError::Preflight(format!("Failed to spawn msiexec: {}", e)))?;
let code = status.code().unwrap_or(-1);
if code == MSI_EXIT_REBOOT_REQUIRED {
return Err(StrategyError::Runtime(format!(
"MSI install completed but requires a reboot to finalize (msiexec exit {}). Reboot, then verify with `nd300 --version`.",
MSI_EXIT_REBOOT_REQUIRED
)));
}
if !status.success() {
return Err(StrategyError::Runtime(format!(
"msiexec exited with code {} (likely user cancel, UAC denied, or install error)",
code
)));
}
verify_post_install(latest).map_err(StrategyError::Runtime)?;
Ok(())
})();
let _ = std::fs::remove_file(&temp_path);
result
}
#[cfg(windows)]
fn try_exe_install(url: &str, latest: &str) -> Result<(), StrategyError> {
let temp_path = std::env::temp_dir().join(format!("nd300-update-{}-setup.exe", latest));
let result = (|| -> Result<(), StrategyError> {
println!(" Downloading EXE installer...");
download_to_file(url, &temp_path)
.map_err(|e| StrategyError::Runtime(format!("Download failed: {}", e)))?;
verify_checksum(&temp_path, url).map_err(StrategyError::Runtime)?;
println!(" Launching Inno Setup installer...");
let status = Command::new(&temp_path)
.args(["/SILENT", "/SUPPRESSMSGBOXES", "/NORESTART"])
.status()
.map_err(|e| {
StrategyError::Preflight(format!("Failed to spawn EXE installer: {}", e))
})?;
if !status.success() {
return Err(StrategyError::Runtime(format!(
"EXE installer exited with code {} (likely user cancel, UAC denied, or install error)",
status.code().unwrap_or(-1)
)));
}
verify_post_install(latest).map_err(StrategyError::Runtime)?;
Ok(())
})();
let _ = std::fs::remove_file(&temp_path);
result
}
#[cfg(not(windows))]
fn try_msi_install(_url: &str, _latest: &str) -> Result<(), StrategyError> {
Err(StrategyError::Preflight(
"MSI installer is Windows-only".into(),
))
}
#[cfg(not(windows))]
fn try_exe_install(_url: &str, _latest: &str) -> Result<(), StrategyError> {
Err(StrategyError::Preflight(
"EXE installer is Windows-only".into(),
))
}
#[cfg(windows)]
fn read_install_source_marker() -> Option<InstallOrigin> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let key = hkcu.open_subkey("Software\\ND300").ok()?;
let value: String = key.get_value("InstallSource").ok()?;
match value.as_str() {
"msi-global" => Some(InstallOrigin::MsiGlobal),
"msi-corporate" => Some(InstallOrigin::MsiCorporate),
"exe-global" => Some(InstallOrigin::ExeGlobal),
"exe-corporate" => Some(InstallOrigin::ExeCorporate),
_ => None,
}
}
#[cfg(windows)]
fn detect_install_origin() -> InstallOrigin {
if let Some(origin) = read_install_source_marker() {
return origin;
}
let Ok(exe) = std::env::current_exe() else {
return InstallOrigin::Unknown;
};
classify_install_path(&exe.to_string_lossy())
}
#[cfg(windows)]
pub(crate) fn classify_install_path(exe_path: &str) -> InstallOrigin {
let lower = exe_path.to_lowercase();
if lower.contains("\\program files\\nd300\\") {
InstallOrigin::MsiGlobal
} else if lower.contains("\\appdata\\local\\programs\\nd300\\") {
InstallOrigin::MsiCorporate
} else if lower.contains("\\.cargo\\bin\\") {
InstallOrigin::CargoOrInstaller
} else {
InstallOrigin::Unknown
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unix_with_cargo_orders_cargo_first() {
assert_eq!(
order_strategies(true, TargetOs::Unix),
vec![
UpdateStrategy::Cargo,
UpdateStrategy::InstallerCurl,
UpdateStrategy::InstallerWget,
]
);
}
#[test]
fn unix_without_cargo_prunes_cargo() {
assert_eq!(
order_strategies(false, TargetOs::Unix),
vec![UpdateStrategy::InstallerCurl, UpdateStrategy::InstallerWget,]
);
}
#[test]
fn windows_with_cargo_orders_cargo_first() {
assert_eq!(
order_strategies(true, TargetOs::Windows),
vec![
UpdateStrategy::Cargo,
UpdateStrategy::InstallerPowerShell,
UpdateStrategy::InstallerPwsh,
]
);
}
#[test]
fn windows_without_cargo_prunes_cargo() {
assert_eq!(
order_strategies(false, TargetOs::Windows),
vec![
UpdateStrategy::InstallerPowerShell,
UpdateStrategy::InstallerPwsh,
]
);
}
#[test]
fn json_method_maps_to_legacy_taxonomy() {
assert_eq!(UpdateStrategy::Cargo.json_method(), "cargo");
assert_eq!(UpdateStrategy::InstallerCurl.json_method(), "installer");
assert_eq!(UpdateStrategy::InstallerWget.json_method(), "installer");
assert_eq!(
UpdateStrategy::InstallerPowerShell.json_method(),
"installer"
);
assert_eq!(UpdateStrategy::InstallerPwsh.json_method(), "installer");
assert_eq!(UpdateStrategy::MsiGlobal.json_method(), "installer");
assert_eq!(UpdateStrategy::MsiCorporate.json_method(), "installer");
assert_eq!(UpdateStrategy::ExeGlobal.json_method(), "installer");
assert_eq!(UpdateStrategy::ExeCorporate.json_method(), "installer");
}
#[test]
fn new_strategies_have_stable_json_ids() {
assert_eq!(UpdateStrategy::MsiGlobal.json_id(), "msi_global");
assert_eq!(UpdateStrategy::MsiCorporate.json_id(), "msi_corporate");
assert_eq!(UpdateStrategy::ExeGlobal.json_id(), "exe_global");
assert_eq!(UpdateStrategy::ExeCorporate.json_id(), "exe_corporate");
}
#[test]
fn new_strategies_have_distinct_labels() {
let labels = [
UpdateStrategy::MsiGlobal.label(),
UpdateStrategy::MsiCorporate.label(),
UpdateStrategy::ExeGlobal.label(),
UpdateStrategy::ExeCorporate.label(),
UpdateStrategy::Cargo.label(),
UpdateStrategy::InstallerPowerShell.label(),
UpdateStrategy::InstallerPwsh.label(),
UpdateStrategy::InstallerCurl.label(),
UpdateStrategy::InstallerWget.label(),
];
let unique: std::collections::HashSet<_> = labels.iter().collect();
assert_eq!(
unique.len(),
labels.len(),
"all strategy labels must be unique"
);
}
#[test]
fn is_newer_basic() {
assert!(is_newer("3.0.0", "3.0.1"));
assert!(is_newer("2.9.0", "3.0.0"));
assert!(!is_newer("3.0.1", "3.0.0"));
assert!(!is_newer("3.0.1", "3.0.1"));
assert!(is_newer("3.0", "3.0.1"));
}
#[test]
fn is_newer_different_lengths() {
assert!(is_newer("1.0", "1.0.1"));
assert!(!is_newer("1.0.1", "1.0"));
}
#[test]
fn is_newer_major_versions() {
assert!(is_newer("3.8.0", "4.0.0"));
assert!(!is_newer("4.0.0", "3.99.99"));
}
#[test]
fn cargo_failure_detection_catches_existing_nd300_or_speedqx_binary() {
assert!(cargo_failure_suggests_existing_binary_collision(
"error: binary `nd300` already exists in destination"
));
assert!(cargo_failure_suggests_existing_binary_collision(
"error: failed to install binary as `speedqx` because it already exists"
));
assert!(!cargo_failure_suggests_existing_binary_collision(
"error: failed to compile dependency"
));
}
#[test]
fn cargo_success_cleans_only_shadowing_non_cargo_current_install() {
assert!(current_install_shadows_cargo_install(
std::path::Path::new("/usr/local/bin/nd300"),
std::path::Path::new("/home/alice/.cargo/bin"),
));
assert!(!current_install_shadows_cargo_install(
std::path::Path::new("/repo/target/debug/nd300"),
std::path::Path::new("/home/alice/.cargo/bin"),
));
assert!(!current_install_shadows_cargo_install(
std::path::Path::new("/home/alice/.cargo/bin/nd300"),
std::path::Path::new("/home/alice/.cargo/bin"),
));
}
fn cleanup_report(removed: bool, scheduled: bool) -> super::super::uninstall::CleanupReport {
super::super::uninstall::CleanupReport {
binary_removed: removed,
binary_removal_scheduled: scheduled,
sibling_removed: false,
receipt_removed: false,
path_cleaned: false,
notes: Vec::new(),
}
}
#[test]
fn shadow_cleanup_decision_maps_report_to_outcome() {
assert_eq!(
classify_shadow_cleanup(&cleanup_report(true, false)),
ShadowCleanupDecision::Removed
);
assert_eq!(
classify_shadow_cleanup(&cleanup_report(false, true)),
ShadowCleanupDecision::Scheduled
);
assert_eq!(
classify_shadow_cleanup(&cleanup_report(false, false)),
ShadowCleanupDecision::NotRemoved
);
assert_eq!(
classify_shadow_cleanup(&cleanup_report(true, true)),
ShadowCleanupDecision::Removed
);
}
#[test]
fn strip_prerelease_metadata_handles_prereleases() {
assert_eq!(strip_prerelease_metadata("3.1.2-rc.1"), "3.1.2");
assert_eq!(strip_prerelease_metadata("3.1.2-alpha.10"), "3.1.2");
}
#[test]
fn strip_prerelease_metadata_handles_build_metadata() {
assert_eq!(strip_prerelease_metadata("3.1.2+nightly.42"), "3.1.2");
assert_eq!(strip_prerelease_metadata("3.1.2+sha.abc123"), "3.1.2");
}
#[test]
fn strip_prerelease_metadata_leaves_clean_version_alone() {
assert_eq!(strip_prerelease_metadata("3.1.2"), "3.1.2");
assert_eq!(strip_prerelease_metadata("1.0"), "1.0");
assert_eq!(strip_prerelease_metadata(""), "");
}
#[test]
fn is_newer_treats_prerelease_of_higher_triple_as_newer() {
assert!(is_newer("3.1.1", "3.1.2-rc.1"));
}
#[test]
fn is_newer_treats_stable_as_newer_than_prerelease_of_same_triple() {
assert!(is_newer("3.1.2-rc.1", "3.1.2"));
}
#[test]
fn is_newer_treats_two_prereleases_of_same_triple_as_equal() {
assert!(!is_newer("3.1.2-rc.1", "3.1.2-rc.2"));
}
#[test]
fn is_newer_handles_build_metadata_as_equal_when_triples_match() {
assert!(!is_newer("3.1.2", "3.1.2+sha.deadbeef"));
assert!(!is_newer("3.1.2+nightly.41", "3.1.2+nightly.42"));
}
#[test]
fn post_install_version_ok_matches_after_stripping() {
assert!(post_install_version_ok("3.1.0", "3.1.0"));
assert!(post_install_version_ok("3.1.0+sha.abc123", "3.1.0"));
assert!(!post_install_version_ok("3.0.11", "3.1.0"));
assert!(!post_install_version_ok("", "3.1.0"));
}
#[test]
fn post_install_version_ok_drives_cargo_verify_logic() {
assert!(post_install_version_ok("3.1.0", "3.1.0"));
assert!(!post_install_version_ok("3.0.11", "3.1.0"));
}
#[test]
fn checksum_verdict_accepts_match_and_refuses_mismatch() {
let hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(checksum_verdict(hash, hash).is_ok());
assert!(checksum_verdict(&hash.to_uppercase(), hash).is_ok());
let err = checksum_verdict("deadbeef", hash).unwrap_err();
assert!(err.contains("SHA256 mismatch"), "err: {err}");
}
#[test]
fn http_status_message_explains_rate_limit() {
assert!(http_status_message(403, Some("0")).contains("rate limit"));
assert!(http_status_message(403, None).contains("HTTP 403"));
assert!(http_status_message(500, None).contains("HTTP 500"));
}
#[cfg(windows)]
#[test]
fn parse_sha256_sidecar_accepts_cargo_dist_format() {
let line = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 *nd300-x86_64-pc-windows-msvc.msi";
assert_eq!(
parse_sha256_sidecar(line).as_deref(),
Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
);
}
#[cfg(windows)]
#[test]
fn parse_sha256_sidecar_accepts_no_asterisk_variant() {
let line = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef nd300.msi";
assert_eq!(
parse_sha256_sidecar(line).as_deref(),
Some("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"),
);
}
#[cfg(windows)]
#[test]
fn parse_sha256_sidecar_normalizes_to_lowercase() {
let line = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 *foo.msi";
assert_eq!(
parse_sha256_sidecar(line).as_deref(),
Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
);
}
#[cfg(windows)]
#[test]
fn parse_sha256_sidecar_rejects_wrong_length() {
assert_eq!(parse_sha256_sidecar("abcdef *foo.msi"), None);
let too_long = format!("{}0 *foo.msi", "a".repeat(64));
assert_eq!(parse_sha256_sidecar(&too_long), None);
assert_eq!(parse_sha256_sidecar(""), None);
}
#[cfg(windows)]
#[test]
fn parse_sha256_sidecar_rejects_non_hex_chars() {
let bad = format!("{} *foo.msi", "g".repeat(64));
assert_eq!(parse_sha256_sidecar(&bad), None);
}
#[cfg(windows)]
#[test]
fn compute_sha256_matches_known_value() {
let dir = std::env::temp_dir().join(format!("nd300-update-tests-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("empty.bin");
std::fs::write(&path, b"").unwrap();
let hash = compute_sha256(&path).unwrap();
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(windows)]
#[test]
fn install_origin_classify_program_files_is_msi_global() {
assert_eq!(
classify_install_path(r"C:\Program Files\nd300\bin\nd300.exe"),
InstallOrigin::MsiGlobal,
);
assert_eq!(
classify_install_path(r"c:\PROGRAM FILES\nd300\BIN\nd300.exe"),
InstallOrigin::MsiGlobal,
);
}
#[cfg(windows)]
#[test]
fn install_origin_classify_localappdata_is_msi_corporate() {
assert_eq!(
classify_install_path(r"C:\Users\alice\AppData\Local\Programs\nd300\bin\nd300.exe"),
InstallOrigin::MsiCorporate,
);
}
#[cfg(windows)]
#[test]
fn install_origin_classify_cargo_bin_is_cargo_or_installer() {
assert_eq!(
classify_install_path(r"C:\Users\alice\.cargo\bin\nd300.exe"),
InstallOrigin::CargoOrInstaller,
);
}
#[cfg(windows)]
#[test]
fn install_origin_classify_random_path_is_unknown() {
assert_eq!(
classify_install_path(r"D:\portable\nd300\nd300.exe"),
InstallOrigin::Unknown,
);
assert_eq!(
classify_install_path(r"C:\Users\alice\Downloads\nd300.exe"),
InstallOrigin::Unknown,
);
}
#[cfg(windows)]
#[test]
fn install_origin_json_ids_are_kebab_case() {
assert_eq!(InstallOrigin::MsiGlobal.json_id(), "msi-global");
assert_eq!(InstallOrigin::MsiCorporate.json_id(), "msi-corporate");
assert_eq!(InstallOrigin::ExeGlobal.json_id(), "exe-global");
assert_eq!(InstallOrigin::ExeCorporate.json_id(), "exe-corporate");
assert_eq!(
InstallOrigin::CargoOrInstaller.json_id(),
"cargo-or-installer"
);
assert_eq!(InstallOrigin::Unknown.json_id(), "unknown");
}
}