use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, anyhow};
use nostr_vpn_core::updater::{
ProductUpdateMode, ProductUpdateResult, ProductUpdateSource, check_product_update,
download_product_update,
};
use super::{PRODUCT_VERSION, UpdateArgs, UpdateSource};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UpdateMode {
Cli,
App,
}
impl UpdateMode {
fn from_args(args: &UpdateArgs) -> Self {
if args.app { Self::App } else { Self::Cli }
}
fn core(self) -> ProductUpdateMode {
match self {
Self::Cli => ProductUpdateMode::Cli,
Self::App => ProductUpdateMode::App,
}
}
fn noun(self) -> &'static str {
match self {
Self::Cli => "nvpn CLI",
Self::App => "Nostr VPN app",
}
}
}
pub(crate) async fn run_update(args: UpdateArgs) -> Result<()> {
let mode = UpdateMode::from_args(&args);
let source = core_source(args.source);
if args.check {
let check = check_product_update(PRODUCT_VERSION, mode.core(), source).await?;
print_update_check(mode, &check, args.json)?;
return Ok(());
}
let check = check_product_update(PRODUCT_VERSION, mode.core(), source).await?;
if !check.available && !args.force {
print_up_to_date(mode, &check, args.json)?;
return Ok(());
}
let temp_dir = create_temp_dir("nvpn-update")?;
let download_parent = args.download_dir.as_deref().unwrap_or(&temp_dir);
let download =
download_product_update(PRODUCT_VERSION, mode.core(), source, Some(download_parent))
.await?;
let archive_path = download
.path
.as_deref()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("updater did not return a downloaded file"))?;
if args.download_only || mode == UpdateMode::App {
print_downloaded(&download, args.json)?;
return Ok(());
}
install_cli_archive(&archive_path, &temp_dir, args.path.as_deref())?;
let _ = fs::remove_dir_all(&temp_dir);
println!(
"updated nvpn at {} from {PRODUCT_VERSION} to {}",
args.path
.as_deref()
.map_or_else(current_exe_display, |path| path.display().to_string()),
download.tag
);
Ok(())
}
fn core_source(source: UpdateSource) -> ProductUpdateSource {
match source {
UpdateSource::Auto => ProductUpdateSource::Auto,
UpdateSource::Github => ProductUpdateSource::Github,
UpdateSource::Hashtree => ProductUpdateSource::Hashtree,
}
}
fn print_update_check(mode: UpdateMode, result: &ProductUpdateResult, json: bool) -> Result<()> {
if json {
print_update_json(result)?;
return Ok(());
}
if result.available {
println!(
"update available: {} -> {}",
result.current_version, result.tag
);
} else {
println!("{} {} is up to date", mode.noun(), result.current_version);
}
println!("asset={}", result.asset);
println!("source={}", result.source);
println!("verified={}", result.verified);
if let Some(url) = result.url.as_deref() {
println!("url={url}");
}
if let Some(path) = result.path.as_deref() {
println!("path={path}");
}
Ok(())
}
fn print_downloaded(result: &ProductUpdateResult, json: bool) -> Result<()> {
if json {
print_update_json(result)?;
return Ok(());
}
println!("downloaded {}", result.asset);
if let Some(path) = result.path.as_deref() {
println!("path={path}");
}
println!("source={}", result.source);
println!("verified={}", result.verified);
if let Some(url) = result.url.as_deref() {
println!("url={url}");
}
Ok(())
}
fn print_up_to_date(mode: UpdateMode, result: &ProductUpdateResult, json: bool) -> Result<()> {
if json {
print_update_json(result)?;
return Ok(());
}
println!("{} {} is up to date", mode.noun(), result.current_version);
Ok(())
}
fn print_update_json(output: &ProductUpdateResult) -> Result<()> {
println!("{}", serde_json::to_string(output)?);
Ok(())
}
fn install_cli_archive(
archive_path: &Path,
temp_dir: &Path,
destination: Option<&Path>,
) -> Result<()> {
extract_archive(archive_path, temp_dir)?;
let binary = find_nvpn_binary(temp_dir)?;
let destination = destination
.map(Path::to_path_buf)
.map(Ok)
.unwrap_or_else(|| {
std::env::current_exe().context("failed to resolve current executable")
})?;
install_parent(&destination)?;
install_bundled_helpers(&binary, &destination)?;
install_binary(&binary, &destination)
}
fn current_exe_display() -> String {
std::env::current_exe().map_or_else(
|_| "<current executable>".to_string(),
|path| path.display().to_string(),
)
}
fn extract_archive(archive_path: &Path, destination: &Path) -> Result<()> {
let mut command = Command::new("tar");
if archive_path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name.ends_with(".tar.gz") || name.ends_with(".tgz"))
{
command.arg("-xzf");
} else {
command.arg("-xf");
}
let output = command
.arg(archive_path)
.arg("-C")
.arg(destination)
.output()
.with_context(|| format!("failed to extract {}", archive_path.display()))?;
if !output.status.success() {
return Err(anyhow!(
"{}",
command_error("archive extraction failed", &output)
));
}
Ok(())
}
fn find_nvpn_binary(root: &Path) -> Result<PathBuf> {
let binary_name = if cfg!(target_os = "windows") {
"nvpn.exe"
} else {
"nvpn"
};
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
for entry in
fs::read_dir(&path).with_context(|| format!("failed to read {}", path.display()))?
{
let entry = entry?;
let path = entry.path();
let metadata = entry.metadata()?;
if metadata.is_dir() {
stack.push(path);
} else if metadata.is_file()
&& path.file_name().and_then(|value| value.to_str()) == Some(binary_name)
{
return Ok(path);
}
}
}
Err(anyhow!("downloaded archive did not contain {binary_name}"))
}
fn install_binary(source: &Path, destination: &Path) -> Result<()> {
let parent = install_parent(destination)?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
let temp_path = parent.join(format!(
".nvpn-update-{}-{}{}",
std::process::id(),
unix_timestamp(),
std::env::consts::EXE_SUFFIX
));
if temp_path.exists() {
let _ = fs::remove_file(&temp_path);
}
fs::copy(source, &temp_path).with_context(|| {
format!(
"failed to copy {} to {}",
source.display(),
temp_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o755)).with_context(|| {
format!(
"failed to set executable permissions on {}",
temp_path.display()
)
})?;
}
#[cfg(target_os = "windows")]
if destination.exists() {
fs::remove_file(destination)
.with_context(|| format!("failed to replace {}", destination.display()))?;
}
fs::rename(&temp_path, destination).with_context(|| {
format!(
"failed to move {} into {}",
temp_path.display(),
destination.display()
)
})?;
Ok(())
}
fn install_parent(destination: &Path) -> Result<&Path> {
if destination.as_os_str().is_empty() {
return Err(anyhow!("install path must not be empty"));
}
if destination.is_dir() {
return Err(anyhow!(
"install path points to a directory: {}",
destination.display()
));
}
destination.parent().ok_or_else(|| {
anyhow!(
"install path must include parent directory: {}",
destination.display()
)
})
}
#[cfg(target_os = "windows")]
fn install_bundled_helpers(source_binary: &Path, destination_binary: &Path) -> Result<()> {
let Some(source_dir) = bundled_helper_source_dir(source_binary) else {
return Ok(());
};
let destination_dir = install_parent(destination_binary)?.join("binaries");
install_bundled_helper_dir(&source_dir, &destination_dir)?;
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn install_bundled_helpers(_source_binary: &Path, _destination_binary: &Path) -> Result<()> {
Ok(())
}
#[cfg(any(target_os = "windows", test))]
fn bundled_helper_source_dir(source_binary: &Path) -> Option<PathBuf> {
source_binary
.parent()
.map(|parent| parent.join("binaries"))
.filter(|path| path.is_dir())
}
#[cfg(any(target_os = "windows", test))]
fn install_bundled_helper_dir(source_dir: &Path, destination_dir: &Path) -> Result<Vec<PathBuf>> {
let mut installed = Vec::new();
let mut stack = vec![source_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in
fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
stack.push(path);
continue;
}
if !file_type.is_file() {
continue;
}
let relative = path
.strip_prefix(source_dir)
.with_context(|| format!("failed to relativize {}", path.display()))?;
let destination = destination_dir.join(relative);
install_helper_file(&path, &destination)?;
installed.push(destination);
}
}
Ok(installed)
}
#[cfg(any(target_os = "windows", test))]
fn install_helper_file(source: &Path, destination: &Path) -> Result<()> {
let parent = destination.parent().ok_or_else(|| {
anyhow!(
"helper install path must include parent directory: {}",
destination.display()
)
})?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
let file_name = destination
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("helper");
let temp_path = destination.with_file_name(format!(
".nvpn-update-{}-{}-{file_name}",
std::process::id(),
unix_timestamp(),
));
if temp_path.exists() {
let _ = fs::remove_file(&temp_path);
}
fs::copy(source, &temp_path).with_context(|| {
format!(
"failed to copy helper {} to {}",
source.display(),
temp_path.display()
)
})?;
if destination.exists() {
fs::remove_file(destination)
.with_context(|| format!("failed to replace {}", destination.display()))?;
}
fs::rename(&temp_path, destination).with_context(|| {
format!(
"failed to move helper {} into {}",
temp_path.display(),
destination.display()
)
})?;
Ok(())
}
fn create_temp_dir(prefix: &str) -> Result<PathBuf> {
let base = std::env::temp_dir();
for attempt in 0..128u32 {
let path = base.join(format!(
"{prefix}-{}-{}-{attempt}",
std::process::id(),
unix_timestamp()
));
match fs::create_dir(&path) {
Ok(()) => return Ok(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(error).with_context(|| format!("failed to create {}", path.display()));
}
}
}
Err(anyhow!("failed to allocate temporary update directory"))
}
fn command_error(prefix: &str, output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stderr.is_empty() {
format!("{prefix}: {stderr}")
} else if !stdout.is_empty() {
format!("{prefix}: {stdout}")
} else {
format!("{prefix}: exit {}", output.status)
}
}
fn unix_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos();
std::env::temp_dir().join(format!("nvpn-updater-{label}-{nonce}"))
}
#[test]
fn helper_source_dir_is_next_to_downloaded_binary() {
let root = unique_temp_dir("helper-source");
let binary = root.join("archive").join("nvpn.exe");
let helper_dir = binary.parent().expect("binary parent").join("binaries");
fs::create_dir_all(&helper_dir).expect("create helper dir");
fs::write(&binary, b"exe").expect("write binary placeholder");
fs::write(helper_dir.join("wintun.dll"), b"dll").expect("write helper placeholder");
assert_eq!(bundled_helper_source_dir(&binary), Some(helper_dir));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn installs_bundled_helper_files_recursively() {
let root = unique_temp_dir("helper-install");
let source_dir = root.join("source").join("binaries");
let destination_dir = root.join("install").join("binaries");
fs::create_dir_all(source_dir.join("drivers")).expect("create source dirs");
fs::create_dir_all(&destination_dir).expect("create destination dir");
fs::write(source_dir.join("wintun.dll"), b"new").expect("write wintun helper");
fs::write(source_dir.join("drivers").join("extra.dll"), b"extra")
.expect("write nested helper");
fs::write(destination_dir.join("wintun.dll"), b"old").expect("write old helper");
let installed = install_bundled_helper_dir(&source_dir, &destination_dir)
.expect("install bundled helpers");
assert!(installed.contains(&destination_dir.join("wintun.dll")));
assert!(installed.contains(&destination_dir.join("drivers").join("extra.dll")));
assert_eq!(
fs::read(destination_dir.join("wintun.dll")).expect("read installed wintun"),
b"new"
);
assert_eq!(
fs::read(destination_dir.join("drivers").join("extra.dll"))
.expect("read installed nested helper"),
b"extra"
);
let _ = fs::remove_dir_all(&root);
}
}