use anyhow::{Context, Result};
use clap::Args;
use semver::Version;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(target_os = "macos")]
use super::service::generate_wrapper_script;
#[cfg(target_os = "linux")]
use super::service::{generate_system_service_file, generate_user_service_file};
const GITHUB_API_URL: &str = "https://api.github.com/repos/freenet/freenet-core/releases/latest";
pub const EXIT_CODE_ALREADY_UP_TO_DATE: i32 = 2;
pub const EXIT_CODE_BUNDLE_UPDATE_STAGED: i32 = 44;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateSubprocessOutcome {
BinaryReplaced,
BundleUpdateStaged,
AlreadyUpToDate,
SpawnFailed,
OtherFailure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateCounterAction {
Clear,
Record,
NoChange,
}
pub fn update_counter_action(outcome: UpdateSubprocessOutcome) -> UpdateCounterAction {
match outcome {
UpdateSubprocessOutcome::BinaryReplaced | UpdateSubprocessOutcome::BundleUpdateStaged => {
UpdateCounterAction::Clear
}
UpdateSubprocessOutcome::SpawnFailed => UpdateCounterAction::Record,
UpdateSubprocessOutcome::AlreadyUpToDate | UpdateSubprocessOutcome::OtherFailure => {
UpdateCounterAction::NoChange
}
}
}
pub fn classify_update_subprocess(
result: &std::io::Result<std::process::ExitStatus>,
) -> UpdateSubprocessOutcome {
match result {
Ok(s) if s.success() => UpdateSubprocessOutcome::BinaryReplaced,
Ok(s) if s.code() == Some(EXIT_CODE_BUNDLE_UPDATE_STAGED) => {
UpdateSubprocessOutcome::BundleUpdateStaged
}
Ok(s) if s.code() == Some(EXIT_CODE_ALREADY_UP_TO_DATE) => {
UpdateSubprocessOutcome::AlreadyUpToDate
}
Ok(_) => UpdateSubprocessOutcome::OtherFailure,
Err(_) => UpdateSubprocessOutcome::SpawnFailed,
}
}
#[allow(dead_code)]
pub fn macos_dmg_asset_name(tag_name: &str) -> String {
let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
format!("Freenet-{}.dmg", version)
}
#[derive(Args, Debug, Clone)]
pub struct UpdateCommand {
#[arg(long)]
pub check: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub quiet: bool,
}
impl UpdateCommand {
pub fn run(&self, current_version: &str) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(self.run_async(current_version))
}
async fn run_async(&self, current_version: &str) -> Result<()> {
if !self.quiet {
println!("Current version: {}", current_version);
println!("Checking for updates...");
}
let latest = get_latest_release().await?;
let latest_version = latest.tag_name.trim_start_matches('v');
if !self.quiet {
println!("Latest version: {}", latest_version);
}
let current_ver =
Version::parse(current_version).context("Failed to parse current version as semver")?;
let latest_ver =
Version::parse(latest_version).context("Failed to parse latest version as semver")?;
if !self.force && latest_ver <= current_ver {
if !self.quiet {
println!("You are already running the latest version.");
}
super::auto_update::clear_update_failures();
std::process::exit(EXIT_CODE_ALREADY_UP_TO_DATE);
}
if self.check {
if latest_ver > current_ver && !self.quiet {
println!(
"Update available: {} -> {}",
current_version, latest_version
);
}
return Ok(());
}
if !self.quiet {
println!("Downloading update...");
}
self.download_and_install(&latest).await
}
async fn download_and_install(&self, release: &Release) -> Result<()> {
#[cfg(target_os = "macos")]
{
let running_in_bundle = std::env::current_exe()
.ok()
.and_then(|exe| super::service::macos_app_bundle_path(&exe))
.is_some();
match self.maybe_perform_bundle_update(release).await {
Ok(true) => {
tracing::info!("DMG-swap update staged for release {}", release.tag_name);
if !self.quiet {
println!("Bundle update staged. Freenet will relaunch shortly.");
}
std::process::exit(EXIT_CODE_BUNDLE_UPDATE_STAGED);
}
Ok(false) => {
}
Err(e) if running_in_bundle => {
tracing::warn!(
"DMG-swap bundle update failed for {}: {}. Skipping update to preserve code signature.",
release.tag_name,
e
);
if !self.quiet {
eprintln!(
"Bundle update failed: {e}. Skipping update to avoid corrupting the signed bundle. Next attempt will retry."
);
}
return Ok(());
}
Err(e) => {
if !self.quiet {
eprintln!(
"Bundle update check failed ({e}); continuing with in-place binary replacement."
);
}
}
}
}
let target = get_target_triple();
let extension = get_archive_extension();
let freenet_asset_name = format!("freenet-{}.{}", target, extension);
let fdev_asset_name = format!("fdev-{}.{}", target, extension);
let freenet_asset = release
.assets
.iter()
.find(|a| a.name == freenet_asset_name)
.ok_or_else(|| {
anyhow::anyhow!(
"No binary available for your platform ({}). Available assets: {}",
target,
release
.assets
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
let fdev_asset = release.assets.iter().find(|a| a.name == fdev_asset_name);
let checksums = if let Some(checksums_asset) =
release.assets.iter().find(|a| a.name == "SHA256SUMS.txt")
{
if !self.quiet {
println!("Downloading checksums...");
}
match download_checksums(&checksums_asset.browser_download_url).await {
Ok(c) => Some(c),
Err(e) => {
if !self.quiet {
eprintln!(
"Warning: Failed to download checksums: {}. Continuing without verification.",
e
);
}
None
}
}
} else {
if !self.quiet {
eprintln!(
"Warning: SHA256SUMS.txt not found in release. Continuing without checksum verification."
);
}
None
};
let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
let freenet_archive_path = temp_dir.path().join(&freenet_asset_name);
download_file(
&freenet_asset.browser_download_url,
&freenet_archive_path,
self.quiet,
)
.await?;
if let Some(checksums) = &checksums {
if let Some(expected_hash) = checksums.get(&freenet_asset_name) {
if !self.quiet {
println!("Verifying freenet checksum...");
}
verify_checksum(&freenet_archive_path, expected_hash)?;
} else if !self.quiet {
eprintln!(
"Warning: Checksum not found for {}. Continuing without verification.",
freenet_asset_name
);
}
}
let freenet_extract_dir = temp_dir.path().join("freenet");
fs::create_dir_all(&freenet_extract_dir)?;
let extracted_freenet =
extract_binary(&freenet_archive_path, &freenet_extract_dir, "freenet")?;
let current_exe = std::env::current_exe().context("Failed to get current executable")?;
match replace_binary(&extracted_freenet, ¤t_exe) {
Ok(()) => {
super::auto_update::clear_update_failures();
}
Err(e) => {
super::auto_update::record_update_failure();
return Err(e);
}
}
if !self.quiet {
println!(
"Successfully updated freenet to version {}",
release.tag_name.trim_start_matches('v')
);
}
if let Some(fdev_asset) = fdev_asset {
if !self.quiet {
println!("Downloading fdev...");
}
self.try_update_fdev(
fdev_asset,
&fdev_asset_name,
&checksums,
temp_dir.path(),
¤t_exe,
)
.await;
} else if !self.quiet {
eprintln!("Warning: fdev not found in release assets. Skipping fdev update.");
}
if let Err(e) = ensure_service_file_updated(¤t_exe, self.quiet) {
if !self.quiet {
eprintln!(
"Warning: Failed to update service file: {}. \
Run 'freenet service install' to update manually.",
e
);
}
}
if !self.quiet {
#[cfg(target_os = "linux")]
{
if is_systemd_service_active() {
println!("Restarting Freenet service...");
let status = Command::new("systemctl")
.args(["--user", "restart", "freenet"])
.status();
match status {
Ok(s) if s.success() => println!("Service restarted successfully."),
Ok(_) => eprintln!(
"Warning: Failed to restart service. Run 'freenet service restart' manually."
),
Err(e) => eprintln!(
"Warning: Failed to restart service: {}. Run 'freenet service restart' manually.",
e
),
}
}
}
#[cfg(target_os = "macos")]
{
if is_launchd_service_active() {
println!("Restarting Freenet service...");
if let Err(e) = Command::new("launchctl")
.args(["stop", "org.freenet.node"])
.status()
{
eprintln!("Warning: failed to stop service: {e}");
}
let status = Command::new("launchctl")
.args(["start", "org.freenet.node"])
.status();
match status {
Ok(s) if s.success() => println!("Service restarted successfully."),
Ok(_) => eprintln!(
"Warning: Failed to restart service. Run 'freenet service restart' manually."
),
Err(e) => eprintln!(
"Warning: Failed to restart service: {}. Run 'freenet service restart' manually.",
e
),
}
}
}
#[cfg(target_os = "windows")]
{
if is_windows_wrapper_running() {
println!("Restarting Freenet service...");
let our_pid = std::process::id().to_string();
Command::new("taskkill")
.args([
"/f",
"/im",
"freenet.exe",
"/fi",
&format!("PID ne {}", our_pid),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok();
std::thread::sleep(std::time::Duration::from_secs(2));
let status = Command::new(¤t_exe)
.args(["service", "start"])
.status();
match status {
Ok(s) if s.success() => println!("Service restarted successfully."),
Ok(_) => eprintln!(
"Warning: Failed to restart service. Run 'freenet service start' manually."
),
Err(e) => eprintln!(
"Warning: Failed to restart service: {}. Run 'freenet service start' manually.",
e
),
}
}
}
}
Ok(())
}
async fn try_update_fdev(
&self,
asset: &Asset,
asset_name: &str,
checksums: &Option<Checksums>,
temp_dir: &Path,
freenet_exe: &Path,
) {
let archive_path = temp_dir.join(asset_name);
if let Err(e) = download_file(&asset.browser_download_url, &archive_path, self.quiet).await
{
if !self.quiet {
eprintln!(
"Warning: Failed to download fdev: {}. Skipping fdev update.",
e
);
}
return;
}
if let Some(checksums) = &checksums {
if let Some(expected_hash) = checksums.get(asset_name) {
if !self.quiet {
println!("Verifying fdev checksum...");
}
if let Err(e) = verify_checksum(&archive_path, expected_hash) {
if !self.quiet {
eprintln!(
"Warning: fdev checksum verification failed: {}. Skipping fdev update.",
e
);
}
return;
}
}
}
let extract_dir = temp_dir.join("fdev");
if let Err(e) = fs::create_dir_all(&extract_dir) {
if !self.quiet {
eprintln!(
"Warning: Failed to create fdev extract directory: {}. Skipping fdev update.",
e
);
}
return;
}
let extracted_fdev = match extract_binary(&archive_path, &extract_dir, "fdev") {
Ok(path) => path,
Err(e) => {
if !self.quiet {
eprintln!(
"Warning: Failed to extract fdev: {}. Skipping fdev update.",
e
);
}
return;
}
};
let Some(install_dir) = freenet_exe.parent() else {
if !self.quiet {
eprintln!("Warning: Cannot determine install directory. Skipping fdev update.");
}
return;
};
#[cfg(target_os = "windows")]
let fdev_dest = install_dir.join("fdev.exe");
#[cfg(not(target_os = "windows"))]
let fdev_dest = install_dir.join("fdev");
if let Err(e) = replace_binary(&extracted_fdev, &fdev_dest) {
if !self.quiet {
eprintln!(
"Warning: Failed to update fdev: {}. You can update it manually with: curl -fsSL https://freenet.org/install.sh | sh",
e
);
}
} else if !self.quiet {
println!("Successfully updated fdev.");
}
}
#[cfg(target_os = "macos")]
async fn maybe_perform_bundle_update(&self, release: &Release) -> Result<bool> {
let current_exe = std::env::current_exe()
.context("Failed to resolve current executable for bundle-update check")?;
let Some(bundle_path) = super::service::macos_app_bundle_path(¤t_exe) else {
return Ok(false);
};
let dmg_asset_name = macos_dmg_asset_name(&release.tag_name);
let dmg_asset = release
.assets
.iter()
.find(|a| a.name == dmg_asset_name)
.ok_or_else(|| anyhow::anyhow!("No DMG asset named {} in release", dmg_asset_name))?;
let _update_lock = acquire_update_lock()?;
let checksums = match release.assets.iter().find(|a| a.name == "SHA256SUMS.txt") {
Some(a) => download_checksums(&a.browser_download_url).await.ok(),
None => None,
};
let scratch = tempfile::tempdir().context("Failed to create temp directory")?;
let dmg_path = scratch.path().join(&dmg_asset_name);
download_file(&dmg_asset.browser_download_url, &dmg_path, self.quiet).await?;
match checksums.as_ref().and_then(|c| c.get(&dmg_asset_name)) {
Some(expected) => verify_checksum(&dmg_path, expected)?,
None => anyhow::bail!(
"No SHA256 checksum listed for {}; refusing unverified DMG install. \
Check that the release uploaded SHA256SUMS.txt covering the DMG asset.",
dmg_asset_name
),
}
let mount_point = scratch.path().join("mount");
std::fs::create_dir_all(&mount_point)?;
hdiutil_attach(&dmg_path, &mount_point)?;
let detach_guard = MountDetachOnDrop {
mount_point: mount_point.clone(),
};
let mounted_app = mount_point.join("Freenet.app");
if !mounted_app.exists() {
drop(detach_guard);
anyhow::bail!(
"DMG mounted at {} does not contain Freenet.app",
mount_point.display()
);
}
let staged_app = bundle_staging_path(&bundle_path)?;
if staged_app.exists() {
std::fs::remove_dir_all(&staged_app)
.context("Failed to clean previous staging directory")?;
}
if let Some(parent) = staged_app.parent() {
std::fs::create_dir_all(parent)?;
}
let ditto = std::process::Command::new("/usr/bin/ditto")
.arg(&mounted_app)
.arg(&staged_app)
.output()
.context("Failed to spawn /usr/bin/ditto")?;
drop(detach_guard);
if !ditto.status.success() {
anyhow::bail!(
"ditto copy failed ({}): {}",
ditto.status,
String::from_utf8_lossy(&ditto.stderr).trim()
);
}
let script_path = write_updater_script()?;
spawn_detached_updater(&script_path, &bundle_path, &staged_app)?;
Ok(true)
}
}
#[derive(serde::Deserialize, Debug)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(serde::Deserialize, Debug)]
struct Asset {
name: String,
browser_download_url: String,
}
struct Checksums {
entries: std::collections::HashMap<String, String>,
}
impl Checksums {
fn parse(content: &str) -> Self {
let mut entries = std::collections::HashMap::new();
for line in content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let hash = parts[0].to_string();
let filename = parts[1].to_string();
entries.insert(filename, hash);
}
}
Self { entries }
}
fn get(&self, filename: &str) -> Option<&str> {
self.entries.get(filename).map(|s| s.as_str())
}
}
async fn get_latest_release() -> Result<Release> {
let client = reqwest::Client::builder()
.user_agent("freenet-updater")
.build()?;
let response = client
.get(GITHUB_API_URL)
.send()
.await
.context("Failed to fetch release info")?;
if !response.status().is_success() {
anyhow::bail!(
"GitHub API returned error: {} {}",
response.status(),
response.text().await.unwrap_or_default()
);
}
response
.json::<Release>()
.await
.context("Failed to parse release info")
}
async fn download_checksums(url: &str) -> Result<Checksums> {
let client = reqwest::Client::builder()
.user_agent("freenet-updater")
.build()?;
let response = client
.get(url)
.send()
.await
.context("Failed to download checksums")?;
if !response.status().is_success() {
anyhow::bail!("Failed to download checksums: {}", response.status());
}
let content = response.text().await.context("Failed to read checksums")?;
Ok(Checksums::parse(&content))
}
fn verify_checksum(file_path: &Path, expected_hash: &str) -> Result<()> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = File::open(file_path).context("Failed to open file for checksum")?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = file
.read(&mut buf)
.context("Failed to read file for checksum")?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let result = hasher.finalize();
let actual_hash = result.iter().fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write;
write!(s, "{:02x}", b).expect("writing to String is infallible");
s
});
if actual_hash != expected_hash {
anyhow::bail!(
"Checksum verification failed!\nExpected: {}\nGot: {}\n\
The download may be corrupted or tampered with.",
expected_hash,
actual_hash
);
}
Ok(())
}
fn get_target_triple() -> &'static str {
#[cfg(all(target_arch = "x86_64", target_os = "linux"))]
{
"x86_64-unknown-linux-musl"
}
#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
{
"aarch64-unknown-linux-musl"
}
#[cfg(all(target_arch = "x86_64", target_os = "macos"))]
{
"x86_64-apple-darwin"
}
#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
{
"aarch64-apple-darwin"
}
#[cfg(all(target_arch = "x86_64", target_os = "windows"))]
{
"x86_64-pc-windows-msvc"
}
#[cfg(not(any(
all(target_arch = "x86_64", target_os = "linux"),
all(target_arch = "aarch64", target_os = "linux"),
all(target_arch = "x86_64", target_os = "macos"),
all(target_arch = "aarch64", target_os = "macos"),
all(target_arch = "x86_64", target_os = "windows"),
)))]
{
"unknown"
}
}
fn get_archive_extension() -> &'static str {
#[cfg(target_os = "windows")]
{
"zip"
}
#[cfg(not(target_os = "windows"))]
{
"tar.gz"
}
}
async fn download_file(url: &str, dest: &Path, quiet: bool) -> Result<()> {
let client = reqwest::Client::builder()
.user_agent("freenet-updater")
.build()?;
let response = client
.get(url)
.send()
.await
.context("Failed to download file")?;
if !response.status().is_success() {
anyhow::bail!("Download failed: {}", response.status());
}
let total_size = response.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut file = File::create(dest).context("Failed to create temp file")?;
let mut stream = response.bytes_stream();
use futures::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Error while downloading")?;
file.write_all(&chunk)?;
downloaded += chunk.len() as u64;
if !quiet && total_size > 0 {
let progress = (downloaded as f64 / total_size as f64 * 100.0) as u32;
print!("\rDownloading... {}%\x1b[K", progress);
io::stdout().flush()?;
}
}
if !quiet {
println!("\rDownload complete.\x1b[K");
}
Ok(())
}
fn extract_binary(archive_path: &Path, dest_dir: &Path, name: &str) -> Result<PathBuf> {
let dest_dir_canonical = dest_dir
.canonicalize()
.context("Failed to canonicalize dest dir")?;
let is_zip = archive_path
.extension()
.map(|ext| ext == "zip")
.unwrap_or(false);
if is_zip {
extract_zip(archive_path, dest_dir, &dest_dir_canonical)?;
} else {
extract_tar_gz(archive_path, dest_dir, &dest_dir_canonical)?;
}
#[cfg(target_os = "windows")]
let binary_name = format!("{name}.exe");
#[cfg(not(target_os = "windows"))]
let binary_name = name.to_string();
let binary_path = dest_dir.join(&binary_name);
if !binary_path.exists() {
anyhow::bail!("{name} binary not found in archive");
}
verify_binary(&binary_path)?;
Ok(binary_path)
}
fn extract_tar_gz(archive_path: &Path, dest_dir: &Path, dest_dir_canonical: &Path) -> Result<()> {
let file = File::open(archive_path).context("Failed to open archive")?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
for entry in archive
.entries()
.context("Failed to read archive entries")?
{
let mut entry = entry.context("Failed to read archive entry")?;
let path = entry.path().context("Failed to get entry path")?;
validate_extract_path(dest_dir, dest_dir_canonical, &path)?;
entry
.unpack_in(dest_dir)
.context("Failed to extract entry")?;
}
Ok(())
}
#[cfg(target_os = "windows")]
fn extract_zip(archive_path: &Path, dest_dir: &Path, dest_dir_canonical: &Path) -> Result<()> {
use std::io::Read;
let file = File::open(archive_path).context("Failed to open archive")?;
let mut archive = zip::ZipArchive::new(file).context("Failed to read zip archive")?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
let outpath = match file.enclosed_name() {
Some(path) => dest_dir.join(path),
None => continue,
};
let outpath_str = outpath.to_string_lossy();
validate_extract_path(dest_dir, dest_dir_canonical, Path::new(&*outpath_str))?;
if file.name().ends_with('/') {
fs::create_dir_all(&outpath)?;
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
fs::create_dir_all(p)?;
}
}
let mut outfile = File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn extract_zip(_archive_path: &Path, _dest_dir: &Path, _dest_dir_canonical: &Path) -> Result<()> {
anyhow::bail!("Zip extraction is only supported on Windows")
}
fn validate_extract_path(dest_dir: &Path, dest_dir_canonical: &Path, path: &Path) -> Result<()> {
let entry_dest = dest_dir.join(path);
let entry_canonical = entry_dest
.canonicalize()
.unwrap_or_else(|_| entry_dest.clone());
let check_path = if entry_canonical.exists() {
entry_canonical
} else {
entry_dest
.parent()
.and_then(|p| p.canonicalize().ok())
.unwrap_or_else(|| dest_dir_canonical.to_path_buf())
};
if !check_path.starts_with(dest_dir_canonical) {
anyhow::bail!(
"Security error: archive contains path traversal attempt: {}",
path.display()
);
}
Ok(())
}
fn verify_binary(binary_path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(binary_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(binary_path, perms)?;
}
let output = Command::new(binary_path)
.arg("--version")
.output()
.context("Failed to execute downloaded binary for verification")?;
if !output.status.success() {
anyhow::bail!(
"Downloaded binary failed verification (--version check failed). \
This could indicate a corrupted download or wrong architecture."
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("Freenet") && !stdout.contains("freenet") {
anyhow::bail!(
"Downloaded binary doesn't appear to be Freenet. \
Got: {}",
stdout.trim()
);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn ensure_service_file_updated(binary_path: &Path, quiet: bool) -> Result<()> {
let home_dir = dirs::home_dir();
let user_service_path = home_dir
.as_ref()
.map(|h| h.join(".config/systemd/user/freenet.service"));
if let Some(ref service_path) = user_service_path {
if service_path.exists() {
return update_service_file(binary_path, service_path, false, quiet);
}
}
let system_service_path = Path::new("/etc/systemd/system/freenet.service");
if system_service_path.exists() {
return update_service_file(binary_path, system_service_path, true, quiet);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn update_service_file(
binary_path: &Path,
service_path: &Path,
system_mode: bool,
quiet: bool,
) -> Result<()> {
let content = fs::read_to_string(service_path).context("Failed to read service file")?;
if content.contains("ExecStopPost=")
&& content.contains("SuccessExitStatus=42")
&& content.contains("RestartPreventExitStatus=43")
{
return Ok(()); }
if !quiet {
println!("Updating service file to add auto-update support...");
}
let backup_path = service_path.with_extension("service.bak");
if let Err(e) = fs::copy(service_path, &backup_path) {
if !quiet {
eprintln!(
"Warning: Failed to backup service file: {}. Continuing anyway.",
e
);
}
} else if !quiet {
println!("Backed up existing service file to {:?}", backup_path);
}
let new_content = if system_mode {
let username = content
.lines()
.find_map(|l| l.strip_prefix("User="))
.unwrap_or("freenet");
let home_dir = content
.lines()
.find_map(|l| l.strip_prefix("Environment=HOME="))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(format!("/home/{username}")));
let log_dir = home_dir.join(".local/state/freenet");
generate_system_service_file(binary_path, &log_dir, username, &home_dir)
} else {
let home_dir = dirs::home_dir().context("Failed to get home directory")?;
let log_dir = home_dir.join(".local/state/freenet");
generate_user_service_file(binary_path, &log_dir)
};
fs::write(service_path, new_content).context("Failed to write updated service file")?;
let mut cmd = Command::new("systemctl");
if !system_mode {
cmd.arg("--user");
}
cmd.arg("daemon-reload");
let status = cmd.status().context("Failed to reload systemd")?;
if !status.success() && !quiet {
if system_mode {
eprintln!(
"Warning: Failed to reload systemd daemon. Run 'systemctl daemon-reload' manually."
);
} else {
eprintln!(
"Warning: Failed to reload systemd daemon. Run 'systemctl --user daemon-reload' manually."
);
}
} else if !quiet {
println!("Service file updated with auto-update hook.");
}
Ok(())
}
#[cfg(target_os = "macos")]
fn ensure_service_file_updated(binary_path: &Path, quiet: bool) -> Result<()> {
let home_dir = match dirs::home_dir() {
Some(dir) => dir,
None => return Ok(()), };
let plist_path = home_dir.join("Library/LaunchAgents/org.freenet.node.plist");
if !plist_path.exists() {
return Ok(());
}
let wrapper_path = home_dir.join(".local/bin/freenet-service-wrapper.sh");
let needs_update = if wrapper_path.exists() {
let content = fs::read_to_string(&wrapper_path).context("Failed to read wrapper script")?;
!content.contains("EXIT_CODE=$?") || !content.contains("freenet update")
} else {
true
};
if !needs_update {
return Ok(());
}
if !quiet {
println!("Updating service wrapper to add auto-update support...");
}
let wrapper_dir = wrapper_path
.parent()
.context("Wrapper path has no parent directory")?;
fs::create_dir_all(wrapper_dir).context("Failed to create wrapper directory")?;
if wrapper_path.exists() {
let backup_path = wrapper_path.with_extension("sh.bak");
if let Err(e) = fs::copy(&wrapper_path, &backup_path) {
if !quiet {
eprintln!(
"Warning: Failed to backup wrapper script: {}. Continuing anyway.",
e
);
}
} else if !quiet {
println!("Backed up existing wrapper script to {:?}", backup_path);
}
}
let wrapper_content = generate_wrapper_script(binary_path);
fs::write(&wrapper_path, &wrapper_content).context("Failed to write wrapper script")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&wrapper_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&wrapper_path, perms)?;
}
if !quiet {
println!("Service wrapper updated with auto-update hook.");
}
Ok(())
}
#[cfg(target_os = "windows")]
fn ensure_service_file_updated(binary_path: &Path, quiet: bool) -> Result<()> {
let exe_path_str = binary_path
.to_str()
.context("Executable path contains invalid UTF-8")?;
let run_command = format!("\"{}\" service run-wrapper", exe_path_str);
let hkcu = winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER);
let current_value = hkcu
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")
.ok()
.and_then(|k| k.get_value::<String, _>("Freenet").ok());
let needs_update = match ¤t_value {
None => {
let has_legacy_task = std::process::Command::new("schtasks")
.args(["/query", "/tn", "Freenet"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
has_legacy_task
}
Some(val) => !val.contains("run-wrapper"),
};
if !needs_update {
return Ok(());
}
if !quiet {
println!("Migrating Freenet autostart to registry Run key...");
}
let (run_key, _) = hkcu
.create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")
.context("Failed to open registry Run key")?;
run_key
.set_value("Freenet", &run_command)
.context("Failed to write Freenet registry entry")?;
drop(
std::process::Command::new("schtasks")
.args(["/delete", "/tn", "Freenet", "/f"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status(),
);
if !quiet {
println!("Freenet autostart migrated successfully.");
}
Ok(())
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn ensure_service_file_updated(_binary_path: &Path, _quiet: bool) -> Result<()> {
Ok(())
}
fn replace_binary(new_binary: &Path, dest: &Path) -> Result<()> {
let backup_path = dest.with_extension("old");
let parent_dir = dest
.parent()
.context("Destination path has no parent directory")?;
if backup_path.exists() {
fs::remove_file(&backup_path).context("Failed to remove old backup")?;
}
let file_stem = dest.file_name().unwrap_or_default().to_string_lossy();
let temp_new = parent_dir.join(format!(".{file_stem}.new.tmp"));
fs::copy(new_binary, &temp_new).context("Failed to copy new binary to target directory")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&temp_new)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&temp_new, perms)?;
}
if dest.exists() {
fs::rename(dest, &backup_path)
.context("Failed to backup current binary. You may need to run with sudo.")?;
}
if let Err(e) = fs::rename(&temp_new, dest) {
if backup_path.exists() {
if let Err(restore_err) = fs::rename(&backup_path, dest) {
eprintln!(
"CRITICAL: Failed to restore backup after update failure. \
Original binary may be at: {}",
backup_path.display()
);
eprintln!("Restore error: {}", restore_err);
}
}
drop(fs::remove_file(&temp_new));
return Err(e).context("Failed to install new binary");
}
if backup_path.exists() {
if let Err(e) = fs::remove_file(&backup_path) {
eprintln!(
"Warning: Failed to remove backup file {}: {}",
backup_path.display(),
e
);
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn is_systemd_service_active() -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-active", "--quiet", "freenet"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn is_launchd_service_active() -> bool {
std::process::Command::new("launchctl")
.args(["list", "org.freenet.node"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
fn is_windows_wrapper_running() -> bool {
std::process::Command::new("tasklist")
.args(["/fi", "imagename eq freenet.exe", "/fo", "csv", "/nh"])
.output()
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
stdout.matches("freenet.exe").count() > 1
})
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn bundle_updater_cache_root() -> Result<PathBuf> {
dirs::cache_dir()
.map(|d| d.join("Freenet"))
.context("Could not resolve user cache directory")
}
#[cfg(target_os = "macos")]
fn bundle_staging_path(target_bundle: &Path) -> Result<PathBuf> {
let parent = target_bundle
.parent()
.context("Target bundle has no parent directory")?;
let pid = std::process::id();
Ok(parent.join(format!(".Freenet.app.staging.{pid}")))
}
#[cfg(target_os = "macos")]
fn updater_runtime_dir() -> Result<PathBuf> {
Ok(bundle_updater_cache_root()?.join("updater"))
}
#[cfg(target_os = "macos")]
fn acquire_update_lock() -> Result<UpdateLock> {
use std::os::unix::io::AsRawFd;
let dir = updater_runtime_dir()?;
std::fs::create_dir_all(&dir)?;
let lock_path = dir.join("update.lock");
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.context("Failed to open update lockfile")?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
anyhow::bail!(
"Another Freenet update is already in progress (lockfile held: {})",
lock_path.display()
);
}
Ok(UpdateLock { _file: file })
}
#[cfg(target_os = "macos")]
struct UpdateLock {
_file: std::fs::File,
}
#[cfg(target_os = "macos")]
struct MountDetachOnDrop {
mount_point: PathBuf,
}
#[cfg(target_os = "macos")]
impl Drop for MountDetachOnDrop {
fn drop(&mut self) {
hdiutil_detach(&self.mount_point).ok();
}
}
#[cfg(target_os = "macos")]
fn hdiutil_attach(dmg: &Path, mount_point: &Path) -> Result<()> {
let output = std::process::Command::new("hdiutil")
.args(["attach", "-nobrowse", "-readonly", "-mountpoint"])
.arg(mount_point)
.arg(dmg)
.output()
.context("Failed to spawn hdiutil")?;
if !output.status.success() {
anyhow::bail!(
"hdiutil attach failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
#[cfg(target_os = "macos")]
fn hdiutil_detach(mount_point: &Path) -> Result<()> {
let output = std::process::Command::new("hdiutil")
.args(["detach", "-force"])
.arg(mount_point)
.output()
.context("Failed to spawn hdiutil")?;
if !output.status.success() {
anyhow::bail!(
"hdiutil detach failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
#[cfg(target_os = "macos")]
fn write_updater_script() -> Result<PathBuf> {
const SCRIPT: &str = include_str!("../../../../../scripts/macos-bundle-updater.sh");
let dir = updater_runtime_dir()?;
std::fs::create_dir_all(&dir)?;
let path = dir.join("macos-bundle-updater.sh");
std::fs::write(&path, SCRIPT)?;
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms)?;
Ok(path)
}
#[cfg(target_os = "macos")]
fn spawn_detached_updater(script: &Path, current_app: &Path, staged_app: &Path) -> Result<()> {
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
let log_path = updater_runtime_dir()?.join("updater.log");
let mut cmd = Command::new("/bin/bash");
cmd.arg(script)
.arg(current_app)
.arg(staged_app)
.arg(&log_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
}
libc::setpgid(0, 0);
Ok(())
});
}
cmd.spawn().context("Failed to spawn detached updater")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn exit_with(code: i32) -> std::process::ExitStatus {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code << 8)
}
#[cfg(unix)]
#[test]
fn classify_update_subprocess_success() {
let result: std::io::Result<std::process::ExitStatus> = Ok(exit_with(0));
assert_eq!(
classify_update_subprocess(&result),
UpdateSubprocessOutcome::BinaryReplaced
);
}
#[cfg(unix)]
#[test]
fn classify_update_subprocess_already_up_to_date() {
let result = Ok(exit_with(EXIT_CODE_ALREADY_UP_TO_DATE));
assert_eq!(
classify_update_subprocess(&result),
UpdateSubprocessOutcome::AlreadyUpToDate
);
}
#[cfg(unix)]
#[test]
fn classify_update_subprocess_bundle_update_staged() {
let result = Ok(exit_with(EXIT_CODE_BUNDLE_UPDATE_STAGED));
assert_eq!(
classify_update_subprocess(&result),
UpdateSubprocessOutcome::BundleUpdateStaged
);
}
#[cfg(unix)]
#[test]
fn classify_update_subprocess_other_failure() {
let result = Ok(exit_with(1));
assert_eq!(
classify_update_subprocess(&result),
UpdateSubprocessOutcome::OtherFailure
);
let result = Ok(exit_with(42));
assert_eq!(
classify_update_subprocess(&result),
UpdateSubprocessOutcome::OtherFailure
);
}
#[test]
fn classify_update_subprocess_spawn_error() {
let result: std::io::Result<std::process::ExitStatus> =
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "boom"));
assert_eq!(
classify_update_subprocess(&result),
UpdateSubprocessOutcome::SpawnFailed
);
}
#[test]
fn update_counter_action_matches_outcome_semantics() {
assert_eq!(
update_counter_action(UpdateSubprocessOutcome::BinaryReplaced),
UpdateCounterAction::Clear,
"successful install must clear accumulated failures"
);
assert_eq!(
update_counter_action(UpdateSubprocessOutcome::BundleUpdateStaged),
UpdateCounterAction::Clear,
"macOS DMG-swap commits to the update — clear failures"
);
assert_eq!(
update_counter_action(UpdateSubprocessOutcome::AlreadyUpToDate),
UpdateCounterAction::NoChange,
"already-up-to-date is not an install attempt — don't touch counter"
);
assert_eq!(
update_counter_action(UpdateSubprocessOutcome::SpawnFailed),
UpdateCounterAction::Record,
"spawn failure is environmental and persistent — must lock out"
);
assert_eq!(
update_counter_action(UpdateSubprocessOutcome::OtherFailure),
UpdateCounterAction::NoChange,
"transient network/checksum errors must NOT accumulate (Codex P1)"
);
}
#[test]
fn macos_dmg_asset_name_strips_leading_v() {
assert_eq!(macos_dmg_asset_name("v0.2.49"), "Freenet-0.2.49.dmg");
assert_eq!(
macos_dmg_asset_name("v0.2.49-rc.1"),
"Freenet-0.2.49-rc.1.dmg"
);
}
#[test]
fn macos_dmg_asset_name_passes_through_bare_version() {
assert_eq!(macos_dmg_asset_name("0.2.49"), "Freenet-0.2.49.dmg");
}
#[test]
fn macos_dmg_asset_name_only_strips_one_leading_v() {
assert_eq!(macos_dmg_asset_name("vv0.2.49"), "Freenet-v0.2.49.dmg");
}
}