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;
use super::rollback;
#[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";
const FREENET_RELEASE_PUBKEY: [u8; 32] = [
0xeb, 0x29, 0x86, 0x60, 0x4e, 0x34, 0xa3, 0xf9, 0x85, 0x27, 0x9a, 0x7e, 0x70, 0x85, 0x2f, 0x37,
0x64, 0xd3, 0x34, 0x7f, 0xe8, 0x20, 0x74, 0xd9, 0x2b, 0x1e, 0x4b, 0xc6, 0x33, 0x6f, 0x86, 0x64,
];
#[used]
static FREENET_REVOCATION_PUBKEY: [u8; 32] = [
0xc9, 0x3f, 0x08, 0x6b, 0x6b, 0xe2, 0x06, 0x86, 0x7b, 0x65, 0xa0, 0x55, 0x92, 0xa2, 0x21, 0x46,
0xea, 0x72, 0x14, 0x72, 0x70, 0xd1, 0xb3, 0x56, 0xbc, 0x36, 0x84, 0x55, 0xd1, 0x3d, 0x37, 0x22,
];
const REQUIRE_RELEASE_SIGNATURE: bool = false;
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(Debug)]
struct ReleaseVerificationError(String);
impl std::fmt::Display for ReleaseVerificationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ReleaseVerificationError {}
fn is_release_verification_failure(err: &anyhow::Error) -> bool {
err.downcast_ref::<ReleaseVerificationError>().is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstallOutcome {
Installed,
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
BundleSkipped { verification_failure: bool },
}
#[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 let Some(status) = rollback::post_stop_status_from_env() {
match rollback::handle_post_stop(&status, current_version) {
rollback::PostStopOutcome::Proceed => {
}
rollback::PostStopOutcome::CrashRecorded { crash_count } => {
eprintln!(
"Freenet {current_version}: crash {crash_count}/{} during post-update \
probation (node stop status {status}); not updating, will auto-roll-back \
if it keeps crashing.",
rollback::ROLLBACK_CRASH_THRESHOLD,
);
tracing::warn!(
version = current_version,
crash_count,
node_stop_status = %status,
threshold = rollback::ROLLBACK_CRASH_THRESHOLD,
"Post-update probation crash recorded (#4073)"
);
std::process::exit(EXIT_CODE_ALREADY_UP_TO_DATE);
}
rollback::PostStopOutcome::RolledBack {
restored_version,
bad_version,
} => {
eprintln!(
"Freenet auto-rollback (#4073): version {bad_version} crash-looped after \
an update; restored known-good {restored_version} and pinned {bad_version} \
as known-bad on this node so it will not be re-applied automatically."
);
tracing::error!(
bad_version = %bad_version,
restored_version = %restored_version,
"Auto-rolled back a crash-looping update (#4073)"
);
std::process::exit(0);
}
rollback::PostStopOutcome::RollbackUnavailable { reason } => {
eprintln!(
"Freenet auto-rollback (#4073) could not run ({reason}); leaving the node \
to the supervisor's own crash limiter / operator. Run `freenet update \
--force` once a fixed release is available."
);
tracing::error!(reason = %reason, "Auto-rollback unavailable (#4073)");
std::process::exit(EXIT_CODE_ALREADY_UP_TO_DATE);
}
}
}
if !self.quiet {
println!("Current version: {}", current_version);
println!("Checking for updates...");
}
let latest = get_latest_release(self.force, self.quiet).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();
super::rollback::clear_install_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.force
&& (rollback::is_version_pinned_bad(latest_version)
|| rollback::is_version_install_gated(latest_version))
{
if !self.quiet {
println!(
"Version {latest_version} is locally blocked on this node (crash-loop known-bad \
pin or repeated install failures); refusing to (re-)install it. Run \
`freenet update --force` to override."
);
}
tracing::warn!(
version = latest_version,
"Refusing to install a locally-blocked version (known-bad pin or install-failure \
gate) (#4073)"
);
super::auto_update::clear_update_failures();
std::process::exit(EXIT_CODE_ALREADY_UP_TO_DATE);
}
if !self.quiet {
println!("Downloading update...");
}
let install_result = self.download_and_install(&latest, current_version).await;
match &install_result {
Ok(InstallOutcome::Installed) => super::rollback::clear_install_failures(),
Ok(InstallOutcome::BundleSkipped {
verification_failure,
}) => {
if *verification_failure {
super::rollback::record_install_failure(latest_version);
}
}
Err(e) if is_release_verification_failure(e) => {
super::rollback::record_install_failure(latest_version)
}
Err(_) => {}
}
install_result.map(|_| ())
}
async fn download_and_install(
&self,
release: &Release,
current_version: &str,
) -> Result<InstallOutcome> {
#[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."
);
}
let verification_failure = is_release_verification_failure(&e);
return Ok(InstallOutcome::BundleSkipped {
verification_failure,
});
}
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 = self.download_and_verify_checksums(release).await?;
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?;
let expected_hash = required_checksum(checksums.as_ref(), &freenet_asset_name)?;
if !self.quiet {
println!("Verifying freenet checksum...");
}
verify_checksum(&freenet_archive_path, expected_hash)?;
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")?;
let known_good = match rollback::prepare_known_good_for_install(
current_version,
¤t_exe,
) {
Ok(prepared) => Some(prepared),
Err(e) => {
if !self.quiet {
eprintln!(
"Warning: failed to snapshot known-good binary for crash-loop \
rollback: {e}. Proceeding without rollback protection for this update."
);
}
tracing::warn!(error = %e, "Failed to capture known-good rollback binary (#4073)");
None
}
};
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 let Some((meta, previous_version)) = known_good {
if let Err(e) = rollback::begin_probation(
release.tag_name.trim_start_matches('v'),
&previous_version,
¤t_exe,
&meta,
) {
if !self.quiet {
eprintln!(
"Warning: installed the update but failed to arm crash-loop rollback \
protection: {e}. If this version crash-loops it will NOT auto-roll-back."
);
}
tracing::warn!(error = %e, "Failed to arm crash-loop rollback probation (#4073)");
}
}
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...");
super::service::kill_freenet_service_processes();
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(InstallOutcome::Installed)
}
async fn download_and_verify_checksums(&self, release: &Release) -> Result<Option<Checksums>> {
let Some(checksums_asset) = release.assets.iter().find(|a| a.name == "SHA256SUMS.txt")
else {
return Ok(None);
};
if !self.quiet {
println!("Downloading checksums...");
}
let manifest_bytes = match download_bytes(&checksums_asset.browser_download_url).await {
Ok(bytes) => bytes,
Err(e) => {
if !self.quiet {
eprintln!("Warning: Failed to download checksums: {}.", e);
}
return Ok(None);
}
};
let signature = match release
.assets
.iter()
.find(|a| a.name == "SHA256SUMS.txt.sig")
{
Some(sig_asset) => Some(
download_bytes(&sig_asset.browser_download_url)
.await
.context("Failed to download release signature SHA256SUMS.txt.sig")?,
),
None => None,
};
verify_release_manifest_signature(&manifest_bytes, signature.as_deref(), self.quiet)?;
Ok(Some(Checksums::parse(&String::from_utf8_lossy(
&manifest_bytes,
))))
}
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;
}
match required_checksum(checksums.as_ref(), asset_name) {
Ok(expected_hash) => {
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;
}
}
Err(e) => {
if !self.quiet {
eprintln!("Warning: {} 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 = self.download_and_verify_checksums(release).await?;
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(force: bool, quiet: bool) -> Result<Release> {
if !force && !super::auto_update::try_consume_install_poll() {
if !quiet {
eprintln!(
"Update check rate-limited (too many recent GitHub polls); skipping this cycle. \
Run `freenet update --force` to override."
);
}
tracing::warn!(
"GitHub update poll rate-limited (token bucket empty); exiting as up-to-date (#4073)"
);
std::process::exit(EXIT_CODE_ALREADY_UP_TO_DATE);
}
let client = reqwest::Client::builder()
.user_agent("freenet-updater")
.timeout(std::time::Duration::from_secs(10))
.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_bytes(url: &str) -> Result<Vec<u8>> {
const MAX_ASSET_BYTES: usize = 4 * 1024 * 1024;
use futures::StreamExt;
let client = reqwest::Client::builder()
.user_agent("freenet-updater")
.build()?;
let response = client
.get(url)
.send()
.await
.context("Failed to download release asset")?;
if !response.status().is_success() {
anyhow::bail!("Download failed: {}", response.status());
}
if let Some(len) = response.content_length() {
if len > MAX_ASSET_BYTES as u64 {
anyhow::bail!("release asset is {len} bytes, exceeding the {MAX_ASSET_BYTES}-byte cap");
}
}
let mut buf = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Failed to read release asset body")?;
if buf.len() + chunk.len() > MAX_ASSET_BYTES {
anyhow::bail!("release asset exceeds the {MAX_ASSET_BYTES}-byte cap");
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
fn verify_release_manifest_signature(
manifest_bytes: &[u8],
signature: Option<&[u8]>,
quiet: bool,
) -> Result<()> {
verify_manifest_signature_with(
manifest_bytes,
signature,
&FREENET_RELEASE_PUBKEY,
REQUIRE_RELEASE_SIGNATURE,
quiet,
)
}
fn verify_manifest_signature_with(
manifest_bytes: &[u8],
signature: Option<&[u8]>,
pubkey: &[u8; 32],
require_signature: bool,
quiet: bool,
) -> Result<()> {
use ed25519_dalek::{Signature, VerifyingKey};
let Some(sig_bytes) = signature else {
if require_signature {
anyhow::bail!(
"Release is missing SHA256SUMS.txt.sig but a release signature is required; \
refusing to install. The release must publish a signed checksum manifest."
);
}
if !quiet {
eprintln!(
"Warning: release did not publish SHA256SUMS.txt.sig; \
installing without release-signature verification."
);
}
return Ok(());
};
let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| {
ReleaseVerificationError(format!(
"Release signature has wrong length ({} bytes, expected 64); refusing to install.",
sig_bytes.len()
))
})?;
let parsed_signature = Signature::from_bytes(&sig_array);
let verifying_key = VerifyingKey::from_bytes(pubkey)
.context("Baked-in release public key is invalid; refusing to install")?;
verifying_key
.verify_strict(manifest_bytes, &parsed_signature)
.map_err(|e| {
ReleaseVerificationError(format!(
"Release signature verification failed: {e}. The checksum manifest may be \
corrupted or tampered with; refusing to install."
))
})?;
if !quiet {
println!("Verified release signature.");
}
Ok(())
}
fn required_checksum<'a>(checksums: Option<&'a Checksums>, asset_name: &str) -> Result<&'a str> {
checksums.and_then(|c| c.get(asset_name)).ok_or_else(|| {
anyhow::anyhow!(
"No SHA256 checksum available for {asset_name}; refusing to install an \
unverified binary. The release must publish a SHA256SUMS.txt that lists \
{asset_name}.",
)
})
}
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 {
return Err(ReleaseVerificationError(format!(
"Checksum verification failed!\nExpected: {expected_hash}\nGot: {actual_hash}\n\
The download may be corrupted or tampered with."
))
.into());
}
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 render_current_systemd_unit(
binary_path: &Path,
existing: &str,
system_mode: bool,
) -> Result<String> {
if system_mode {
let username = existing
.lines()
.find_map(|l| l.strip_prefix("User="))
.unwrap_or("freenet");
let home_dir = existing
.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");
Ok(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");
Ok(generate_user_service_file(binary_path, &log_dir))
}
}
#[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")?;
let hash_path = service_path.with_extension("service.hash");
let new_content = render_current_systemd_unit(binary_path, &content, system_mode)?;
let sidecar_raw = fs::read_to_string(&hash_path).ok();
let action = decide_wrapper_action(&new_content, Some(&content), sidecar_raw.as_deref());
match &action {
WrapperAction::UserModified { on_disk_hash } => {
if !quiet {
eprintln!(
"Warning: systemd unit at {} differs from the current Freenet \
template AND from the hash recorded in {}, suggesting it has \
been hand-edited. Leaving it alone. To accept the bundled \
unit, delete the .service.hash sidecar or reinstall via \
`freenet service install`. (on-disk hash: {})",
service_path.display(),
hash_path.display(),
on_disk_hash,
);
}
return Ok(());
}
WrapperAction::UpToDate {
backfill_sidecar: Some(h),
} => {
if let Err(e) = write_wrapper_hash_sidecar(&hash_path, h) {
if !quiet {
eprintln!(
"Warning: failed to backfill service hash sidecar at {}: {}. \
User-modification detection on the next `freenet update` \
will fall back to treating the unit as ours.",
hash_path.display(),
e
);
}
}
return Ok(());
}
WrapperAction::UpToDate {
backfill_sidecar: None,
} => {
return Ok(()); }
WrapperAction::WriteOurs { .. } => {
}
}
let WrapperAction::WriteOurs { new_hash } = &action else {
unreachable!("non-WriteOurs actions returned above");
};
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);
}
fs::write(service_path, &new_content).context("Failed to write updated service file")?;
if let Err(e) = write_wrapper_hash_sidecar(&hash_path, new_hash) {
if !quiet {
eprintln!(
"Warning: failed to write service hash sidecar at {}: {}. \
User-modification detection on the next `freenet update` \
will fall back to treating the unit as ours.",
hash_path.display(),
e
);
}
}
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(())
}
#[allow(dead_code)]
pub(super) fn launchd_plist_needs_regen(content: &str) -> bool {
content.contains("Library/Logs/freenet/freenet.log")
|| content.contains("Library/Logs/freenet/freenet.error.log")
}
#[allow(dead_code)]
pub(super) fn wrapper_needs_regen(current_template: &str, on_disk: &str) -> bool {
current_template != on_disk
}
#[allow(dead_code)]
pub(super) fn is_valid_wrapper_hash(s: &str) -> bool {
s.len() == 64
&& s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[allow(dead_code)]
pub(super) fn wrapper_content_hash(content: &str) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write;
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hasher
.finalize()
.iter()
.fold(String::with_capacity(64), |mut s, b| {
write!(s, "{b:02x}").expect("writing to String is infallible");
s
})
}
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub(super) enum WrapperAction {
UpToDate { backfill_sidecar: Option<String> },
WriteOurs { new_hash: String },
UserModified { on_disk_hash: String },
}
#[allow(dead_code)]
pub(super) fn decide_wrapper_action(
current_template: &str,
on_disk_wrapper: Option<&str>,
sidecar_raw: Option<&str>,
) -> WrapperAction {
let stored_hash = sidecar_raw
.map(|s| s.trim())
.filter(|s| is_valid_wrapper_hash(s));
let template_hash = wrapper_content_hash(current_template);
let Some(on_disk) = on_disk_wrapper else {
return WrapperAction::WriteOurs {
new_hash: template_hash,
};
};
if !wrapper_needs_regen(current_template, on_disk) {
let backfill_sidecar = match stored_hash {
Some(s) if s == template_hash => None,
_ => Some(template_hash),
};
return WrapperAction::UpToDate { backfill_sidecar };
}
let on_disk_hash = wrapper_content_hash(on_disk);
match stored_hash {
Some(s) if s != on_disk_hash => WrapperAction::UserModified { on_disk_hash },
_ => WrapperAction::WriteOurs {
new_hash: template_hash,
},
}
}
#[allow(dead_code)]
pub(super) fn write_wrapper_hash_sidecar(path: &Path, hash: &str) -> std::io::Result<()> {
fs::write(path, format!("{hash}\n"))
}
#[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 wrapper_hash_path = wrapper_path.with_extension("sh.hash");
let new_wrapper_content = generate_wrapper_script(binary_path);
let on_disk_wrapper = fs::read_to_string(&wrapper_path).ok();
let sidecar_raw = fs::read_to_string(&wrapper_hash_path).ok();
let action = decide_wrapper_action(
&new_wrapper_content,
on_disk_wrapper.as_deref(),
sidecar_raw.as_deref(),
);
let plist_needs_update = match fs::read_to_string(&plist_path) {
Ok(content) => launchd_plist_needs_regen(&content),
Err(e) => {
if !quiet {
eprintln!(
"Warning: failed to read launchd plist at {} for migration check: {}. \
Skipping plist regen.",
plist_path.display(),
e
);
}
false
}
};
match &action {
WrapperAction::UserModified { on_disk_hash } => {
if !quiet {
eprintln!(
"Warning: wrapper script at {} differs from the current Freenet \
template AND from the hash recorded in {}, suggesting it has \
been hand-edited. Leaving it alone. To accept the bundled \
wrapper, delete the .sh.hash sidecar or reinstall via \
`freenet service install`. (on-disk hash: {})",
wrapper_path.display(),
wrapper_hash_path.display(),
on_disk_hash,
);
}
}
WrapperAction::UpToDate {
backfill_sidecar: Some(h),
} => {
if let Err(e) = write_wrapper_hash_sidecar(&wrapper_hash_path, h) {
if !quiet {
eprintln!(
"Warning: failed to backfill wrapper hash sidecar at {}: {}.",
wrapper_hash_path.display(),
e
);
}
}
}
WrapperAction::UpToDate {
backfill_sidecar: None,
} => {}
WrapperAction::WriteOurs { new_hash } => {
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);
}
}
fs::write(&wrapper_path, &new_wrapper_content)
.context("Failed to write wrapper script")?;
if let Err(e) = write_wrapper_hash_sidecar(&wrapper_hash_path, new_hash) {
if !quiet {
eprintln!(
"Warning: failed to write wrapper hash sidecar at {}: {}. \
User-modification detection on the next `freenet update` \
will fall back to treating the wrapper as ours.",
wrapper_hash_path.display(),
e
);
}
}
#[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.");
}
}
}
if plist_needs_update {
if !quiet {
println!("Updating launchd plist to drop legacy log paths (issue #4251)...");
}
let log_dir = home_dir.join("Library/Logs/freenet");
let plist_content = super::service::generate_plist(&wrapper_path, &log_dir);
let backup_path = plist_path.with_extension("plist.bak");
if let Err(e) = fs::copy(&plist_path, &backup_path) {
if !quiet {
eprintln!(
"Warning: failed to back up plist: {}. Continuing anyway.",
e
);
}
}
fs::write(&plist_path, plist_content).context("Failed to write updated plist")?;
if !quiet {
println!(
"launchd plist updated. To apply immediately without rebooting, run:\n \
launchctl bootout gui/$(id -u) {plist}\n \
launchctl bootstrap gui/$(id -u) {plist}",
plist = plist_path.display()
);
}
}
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::*;
fn fixture_unit() -> (String, String) {
let t = "[Service]\nExecStart=/usr/local/bin/freenet network\nSuccessExitStatus=42 43\n"
.to_string();
let h = wrapper_content_hash(&t);
(t, h)
}
#[test]
fn systemd_unit_new_directive_drift_is_detected_4287() {
let template = "[Service]\nExecStartPre=/usr/local/bin/freenet preflight\nExecStopPost=-/bin/sh -c '...'\nSuccessExitStatus=42 43\nRestartPreventExitStatus=43\n";
let stale_but_markers_present = "[Service]\nExecStopPost=-/bin/sh -c '...'\nSuccessExitStatus=42 43\nRestartPreventExitStatus=43\n";
assert_eq!(
decide_wrapper_action(template, Some(stale_but_markers_present), None),
WrapperAction::WriteOurs {
new_hash: wrapper_content_hash(template)
},
"a unit missing a new template directive must be detected as \
drifted even though the legacy markers are all present (#4287)"
);
}
#[test]
fn systemd_unit_no_sidecar_stale_migrates_to_ours_4287() {
let (template, template_hash) = fixture_unit();
let legacy = "[Service]\nExecStart=/usr/local/bin/freenet network\nStandardOutput=append:/home/u/.local/state/freenet/freenet.log\n";
assert_eq!(
decide_wrapper_action(&template, Some(legacy), None),
WrapperAction::WriteOurs {
new_hash: template_hash
}
);
}
#[test]
fn systemd_unit_identical_to_template_with_sidecar_is_noop_4287() {
let (template, template_hash) = fixture_unit();
assert_eq!(
decide_wrapper_action(&template, Some(&template), Some(&template_hash)),
WrapperAction::UpToDate {
backfill_sidecar: None
}
);
}
#[test]
fn systemd_unit_identical_to_template_missing_sidecar_backfills_4287() {
let (template, template_hash) = fixture_unit();
assert_eq!(
decide_wrapper_action(&template, Some(&template), None),
WrapperAction::UpToDate {
backfill_sidecar: Some(template_hash)
}
);
}
#[test]
fn systemd_unit_user_modified_is_preserved_4287() {
let (template, _) = fixture_unit();
let edited = "[Service]\nExecStart=/usr/local/bin/freenet network --custom-flag\n";
let edited_hash = wrapper_content_hash(edited);
let prior_freenet_hash =
wrapper_content_hash("[Service]\nExecStart=/usr/local/bin/freenet network\n");
assert_eq!(
decide_wrapper_action(&template, Some(edited), Some(&prior_freenet_hash)),
WrapperAction::UserModified {
on_disk_hash: edited_hash
}
);
}
#[test]
fn systemd_unit_malformed_sidecar_treated_as_missing_4287() {
let (template, template_hash) = fixture_unit();
let stale = "[Service]\nExecStart=/usr/local/bin/freenet network\n# old\n";
assert_eq!(
decide_wrapper_action(&template, Some(stale), Some("deadbe")),
WrapperAction::WriteOurs {
new_hash: template_hash
}
);
}
#[test]
#[cfg(target_os = "linux")]
fn freshly_generated_systemd_unit_does_not_trigger_regen_loop_4287() {
use std::path::PathBuf;
let binary = PathBuf::from("/usr/local/bin/freenet");
let log_dir = PathBuf::from("/home/u/.local/state/freenet");
let user_unit = generate_user_service_file(&binary, &log_dir);
let user_hash = wrapper_content_hash(&user_unit);
assert_eq!(
decide_wrapper_action(&user_unit, Some(&user_unit), Some(&user_hash)),
WrapperAction::UpToDate {
backfill_sidecar: None
},
"a freshly generated user unit with a matching sidecar must be \
a no-op — otherwise every `freenet update` loops rewriting it"
);
let home = PathBuf::from("/home/svc");
let system_unit = generate_system_service_file(&binary, &log_dir, "svc", &home);
let system_hash = wrapper_content_hash(&system_unit);
assert_eq!(
decide_wrapper_action(&system_unit, Some(&system_unit), Some(&system_hash)),
WrapperAction::UpToDate {
backfill_sidecar: None
},
);
}
#[test]
#[cfg(target_os = "linux")]
fn render_current_systemd_unit_round_trips_user_and_home_4287() {
use std::path::PathBuf;
let binary = PathBuf::from("/usr/local/bin/freenet");
let home = PathBuf::from("/home/customsvc");
let log_dir = home.join(".local/state/freenet");
let installed = generate_system_service_file(&binary, &log_dir, "customsvc", &home);
let rendered = render_current_systemd_unit(&binary, &installed, true)
.expect("render must succeed for a well-formed system unit");
assert_eq!(
rendered, installed,
"re-rendering an installed system unit must reproduce it \
byte-for-byte so the equality check is a no-op (#4287)"
);
}
#[test]
#[cfg(target_os = "linux")]
fn render_current_systemd_unit_infers_home_when_env_home_absent_4287() {
use std::path::PathBuf;
let binary = PathBuf::from("/usr/local/bin/freenet");
let existing = "[Service]\nUser=alice\nExecStart=/usr/local/bin/freenet network\n";
let rendered = render_current_systemd_unit(&binary, existing, true)
.expect("render must succeed even without Environment=HOME=");
let inferred_home = PathBuf::from("/home/alice");
let log_dir = inferred_home.join(".local/state/freenet");
let expected = generate_system_service_file(&binary, &log_dir, "alice", &inferred_home);
assert_eq!(
rendered, expected,
"missing Environment=HOME= must infer /home/{{user}} deterministically (#4287)"
);
}
#[test]
fn launchd_plist_with_legacy_log_paths_needs_regen() {
let legacy = r#"<plist version="1.0"><dict>
<key>StandardOutPath</key>
<string>/Users/u/Library/Logs/freenet/freenet.log</string>
<key>StandardErrorPath</key>
<string>/Users/u/Library/Logs/freenet/freenet.error.log</string>
</dict></plist>"#;
assert!(
launchd_plist_needs_regen(legacy),
"legacy plist must be detected as needing regen (#4251)"
);
}
#[test]
fn launchd_plist_with_stderr_only_legacy_path_still_needs_regen() {
let partial = r#"<plist version="1.0"><dict>
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>/Users/u/Library/Logs/freenet/freenet.error.log</string>
</dict></plist>"#;
assert!(
launchd_plist_needs_regen(partial),
"plist with legacy stderr-only path must still need regen (round-3 review)"
);
}
#[test]
#[cfg(target_os = "macos")]
fn freshly_generated_plist_does_not_trigger_regen_loop() {
use std::path::PathBuf;
let wrapper = PathBuf::from("/Users/test/.local/bin/freenet-service-wrapper.sh");
let log_dir = PathBuf::from("/Users/test/Library/Logs/freenet");
let generated = super::super::service::generate_plist(&wrapper, &log_dir);
assert!(
!launchd_plist_needs_regen(&generated),
"freshly-generated plist must not match the legacy-path predicate \
— otherwise every auto-update would loop rewriting it.\n\nGenerated:\n{generated}"
);
}
#[test]
fn launchd_plist_with_dev_null_is_up_to_date() {
let current = r#"<plist version="1.0"><dict>
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>/dev/null</string>
</dict></plist>"#;
assert!(
!launchd_plist_needs_regen(current),
"current /dev/null plist must NOT be flagged for regen"
);
}
#[test]
fn wrapper_with_auto_update_hook_but_no_pkill_block_needs_regen_3967() {
let jan_2026_wrapper = r#"#!/bin/bash
BACKOFF=10
while true; do
/usr/local/bin/freenet network
EXIT_CODE=$?
if [ $EXIT_CODE -eq 42 ]; then
/usr/local/bin/freenet update --quiet
continue
fi
sleep $BACKOFF
done
"#;
let current_template = r#"#!/bin/bash
pkill -f -u "$(id -u)" "freenet network" 2>/dev/null || true
BACKOFF=10
while true; do
/usr/local/bin/freenet network
EXIT_CODE=$?
if [ $EXIT_CODE -eq 42 ]; then
/usr/local/bin/freenet update --quiet
continue
fi
sleep $BACKOFF
done
"#;
assert!(
wrapper_needs_regen(current_template, jan_2026_wrapper),
"Jan 2026 wrapper missing the pkill-on-startup block must \
be detected as needing regen (#3967)."
);
}
#[test]
fn wrapper_identical_to_template_does_not_need_regen() {
let template = "#!/bin/bash\necho hi\n";
assert!(
!wrapper_needs_regen(template, template),
"byte-identical wrapper must NOT be flagged for regen — \
otherwise every `freenet update` would loop rewriting it"
);
}
#[test]
fn wrapper_needs_regen_detects_single_char_drift() {
assert!(
wrapper_needs_regen("BACKOFF=10\n", "BACKOFF=11\n"),
"any drift between template and on-disk wrapper must \
trigger regen (#3967)"
);
}
#[test]
#[cfg(target_os = "macos")]
fn freshly_generated_wrapper_does_not_trigger_regen_loop() {
use std::path::PathBuf;
let binary = PathBuf::from("/Users/test/.local/bin/freenet");
let a = super::super::service::generate_wrapper_script(&binary);
let b = super::super::service::generate_wrapper_script(&binary);
assert!(
!wrapper_needs_regen(&a, &b),
"two calls to generate_wrapper_script with the same \
binary path must produce identical output — \
otherwise every auto-update would loop rewriting the \
wrapper"
);
}
#[test]
fn is_valid_wrapper_hash_accepts_real_sha256_hex_only() {
let good = wrapper_content_hash("anything");
assert!(is_valid_wrapper_hash(&good));
assert!(is_valid_wrapper_hash(&"a".repeat(64)));
assert!(is_valid_wrapper_hash(&"0123456789abcdef".repeat(4)));
assert!(!is_valid_wrapper_hash(""), "empty");
assert!(!is_valid_wrapper_hash("not a hash"), "free text");
assert!(!is_valid_wrapper_hash(&"a".repeat(63)), "truncated");
assert!(!is_valid_wrapper_hash(&"a".repeat(65)), "too long");
assert!(
!is_valid_wrapper_hash(&"A".repeat(64)),
"uppercase rejected — wrapper_content_hash always emits lowercase"
);
assert!(
!is_valid_wrapper_hash(&"g".repeat(64)),
"non-hex char rejected"
);
}
fn fixture_template() -> (String, String) {
let t = "#!/bin/bash\necho fixture\n".to_string();
let h = wrapper_content_hash(&t);
(t, h)
}
#[test]
fn decide_wrapper_action_no_wrapper_writes_ours() {
let (template, hash) = fixture_template();
assert_eq!(
decide_wrapper_action(&template, None, None),
WrapperAction::WriteOurs { new_hash: hash }
);
}
#[test]
fn decide_wrapper_action_stale_wrapper_no_sidecar_writes_ours_3967() {
let (template, hash) = fixture_template();
let stale = "#!/bin/bash\n# stale Jan 2026 wrapper\n";
assert_eq!(
decide_wrapper_action(&template, Some(stale), None),
WrapperAction::WriteOurs { new_hash: hash }
);
}
#[test]
fn decide_wrapper_action_stale_wrapper_matching_sidecar_writes_ours() {
let (template, template_hash) = fixture_template();
let stale = "#!/bin/bash\n# previous Freenet wrapper\n";
let stale_hash = wrapper_content_hash(stale);
assert_eq!(
decide_wrapper_action(&template, Some(stale), Some(&stale_hash)),
WrapperAction::WriteOurs {
new_hash: template_hash
}
);
}
#[test]
fn decide_wrapper_action_stale_wrapper_with_trailing_newline_sidecar() {
let (template, template_hash) = fixture_template();
let stale = "#!/bin/bash\n# previous Freenet wrapper\n";
let sidecar_with_newline = format!("{}\n", wrapper_content_hash(stale));
assert_eq!(
decide_wrapper_action(&template, Some(stale), Some(&sidecar_with_newline)),
WrapperAction::WriteOurs {
new_hash: template_hash
},
"matching sidecar with trailing newline must validate the same as one without",
);
}
#[test]
fn decide_wrapper_action_user_modified_wrapper_is_preserved() {
let (template, _) = fixture_template();
let edited = "#!/bin/bash\n# user customized this\n";
let edited_hash = wrapper_content_hash(edited);
let prior_freenet_hash = wrapper_content_hash("#!/bin/bash\n# prior Freenet\n");
assert_eq!(
decide_wrapper_action(&template, Some(edited), Some(&prior_freenet_hash)),
WrapperAction::UserModified {
on_disk_hash: edited_hash
}
);
}
#[test]
fn decide_wrapper_action_malformed_sidecar_treated_as_missing_3967() {
let (template, hash) = fixture_template();
let stale = "#!/bin/bash\n# stale\n";
assert_eq!(
decide_wrapper_action(&template, Some(stale), Some("deadbe")),
WrapperAction::WriteOurs {
new_hash: hash.clone()
}
);
assert_eq!(
decide_wrapper_action(&template, Some(stale), Some("not a hash at all")),
WrapperAction::WriteOurs {
new_hash: hash.clone()
}
);
assert_eq!(
decide_wrapper_action(&template, Some(stale), Some("")),
WrapperAction::WriteOurs { new_hash: hash }
);
}
#[test]
fn decide_wrapper_action_up_to_date_with_matching_sidecar_is_noop() {
let (template, template_hash) = fixture_template();
assert_eq!(
decide_wrapper_action(&template, Some(&template), Some(&template_hash)),
WrapperAction::UpToDate {
backfill_sidecar: None
}
);
}
#[test]
fn decide_wrapper_action_up_to_date_with_missing_sidecar_backfills() {
let (template, template_hash) = fixture_template();
assert_eq!(
decide_wrapper_action(&template, Some(&template), None),
WrapperAction::UpToDate {
backfill_sidecar: Some(template_hash)
}
);
}
#[test]
fn decide_wrapper_action_up_to_date_with_malformed_sidecar_backfills() {
let (template, template_hash) = fixture_template();
assert_eq!(
decide_wrapper_action(&template, Some(&template), Some("garbage")),
WrapperAction::UpToDate {
backfill_sidecar: Some(template_hash)
}
);
}
#[test]
fn wrapper_content_hash_is_stable_and_distinct() {
let a = wrapper_content_hash("hello");
let b = wrapper_content_hash("hello");
let c = wrapper_content_hash("hello\n"); assert_eq!(a, b, "hash must be deterministic for the same input");
assert_ne!(
a, c,
"hash must distinguish inputs differing by a single byte — \
otherwise the user-modification sidecar can't tell a \
hand-edited wrapper from Freenet's"
);
assert_eq!(
a.len(),
64,
"SHA-256 hex digest must be exactly 64 chars; the wrapper \
sidecar's trim() comparison relies on a stable encoding"
);
}
#[cfg(target_os = "macos")]
static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(target_os = "macos")]
struct ScopedHome {
prior: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
#[cfg(target_os = "macos")]
impl ScopedHome {
fn new(path: &std::path::Path) -> Self {
let lock = HOME_ENV_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let prior = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", path);
}
Self { prior, _lock: lock }
}
}
#[cfg(target_os = "macos")]
impl Drop for ScopedHome {
fn drop(&mut self) {
unsafe {
match self.prior.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
#[test]
#[cfg(target_os = "macos")]
fn ensure_service_file_updated_rewrites_pre_pkill_wrapper_and_records_hash() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().expect("create tempdir");
let fake_home = tmp.path();
let _home = ScopedHome::new(fake_home);
let launch_agents = fake_home.join("Library/LaunchAgents");
fs::create_dir_all(&launch_agents).unwrap();
fs::write(launch_agents.join("org.freenet.node.plist"), "<plist/>").unwrap();
let wrapper_dir = fake_home.join(".local/bin");
fs::create_dir_all(&wrapper_dir).unwrap();
let wrapper = wrapper_dir.join("freenet-service-wrapper.sh");
let stale = "#!/bin/bash\n# EXIT_CODE=$?\n# freenet update\n# (no pkill block!)\n";
fs::write(&wrapper, stale).unwrap();
let hash_sidecar = wrapper.with_extension("sh.hash");
assert!(!hash_sidecar.exists(), "test precondition: no sidecar yet");
let binary = wrapper_dir.join("freenet");
ensure_service_file_updated(&binary, true)
.expect("ensure_service_file_updated should succeed");
let new_content = fs::read_to_string(&wrapper).unwrap();
let expected = super::super::service::generate_wrapper_script(&binary);
assert_eq!(
new_content, expected,
"stale Jan-2026 wrapper must be replaced byte-for-byte by the current template (#3967)"
);
let recorded = fs::read_to_string(&hash_sidecar)
.expect("hash sidecar must be written after a successful regen");
assert_eq!(
recorded.trim(),
wrapper_content_hash(&new_content),
"recorded sidecar hash must match the wrapper we just wrote"
);
}
#[test]
#[cfg(target_os = "macos")]
fn ensure_service_file_updated_is_noop_after_fresh_install() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let fake_home = tmp.path();
let _home = ScopedHome::new(fake_home);
let launch_agents = fake_home.join("Library/LaunchAgents");
fs::create_dir_all(&launch_agents).unwrap();
fs::write(launch_agents.join("org.freenet.node.plist"), "<plist/>").unwrap();
let wrapper_dir = fake_home.join(".local/bin");
fs::create_dir_all(&wrapper_dir).unwrap();
let binary = wrapper_dir.join("freenet");
let wrapper = wrapper_dir.join("freenet-service-wrapper.sh");
let template = super::super::service::generate_wrapper_script(&binary);
fs::write(&wrapper, &template).unwrap();
let sidecar = wrapper.with_extension("sh.hash");
fs::write(&sidecar, format!("{}\n", wrapper_content_hash(&template))).unwrap();
let wrapper_before = fs::metadata(&wrapper).unwrap().modified().unwrap();
let sidecar_before = fs::metadata(&sidecar).unwrap().modified().unwrap();
ensure_service_file_updated(&binary, true).unwrap();
assert_eq!(fs::read_to_string(&wrapper).unwrap(), template);
assert_eq!(
fs::read_to_string(&sidecar).unwrap().trim(),
wrapper_content_hash(&template),
);
assert_eq!(
fs::metadata(&wrapper).unwrap().modified().unwrap(),
wrapper_before,
"wrapper must not be rewritten when already up to date \
— otherwise every `freenet update` loops on it"
);
assert_eq!(
fs::metadata(&sidecar).unwrap().modified().unwrap(),
sidecar_before,
"sidecar must not be rewritten when wrapper is already up to date"
);
}
#[test]
#[cfg(target_os = "macos")]
fn ensure_service_file_updated_backfills_missing_sidecar() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let fake_home = tmp.path();
let _home = ScopedHome::new(fake_home);
let launch_agents = fake_home.join("Library/LaunchAgents");
fs::create_dir_all(&launch_agents).unwrap();
fs::write(launch_agents.join("org.freenet.node.plist"), "<plist/>").unwrap();
let wrapper_dir = fake_home.join(".local/bin");
fs::create_dir_all(&wrapper_dir).unwrap();
let binary = wrapper_dir.join("freenet");
let wrapper = wrapper_dir.join("freenet-service-wrapper.sh");
let template = super::super::service::generate_wrapper_script(&binary);
fs::write(&wrapper, &template).unwrap();
let sidecar = wrapper.with_extension("sh.hash");
assert!(!sidecar.exists(), "precondition: no sidecar");
ensure_service_file_updated(&binary, true).unwrap();
assert_eq!(fs::read_to_string(&wrapper).unwrap(), template);
assert_eq!(
fs::read_to_string(&sidecar).unwrap().trim(),
wrapper_content_hash(&template),
"sidecar must be backfilled when wrapper is up-to-date \
but sidecar was missing"
);
}
#[test]
#[cfg(target_os = "macos")]
fn ensure_service_file_updated_treats_malformed_sidecar_as_missing() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let fake_home = tmp.path();
let _home = ScopedHome::new(fake_home);
let launch_agents = fake_home.join("Library/LaunchAgents");
fs::create_dir_all(&launch_agents).unwrap();
fs::write(launch_agents.join("org.freenet.node.plist"), "<plist/>").unwrap();
let wrapper_dir = fake_home.join(".local/bin");
fs::create_dir_all(&wrapper_dir).unwrap();
let wrapper = wrapper_dir.join("freenet-service-wrapper.sh");
let stale = "#!/bin/bash\n# stale Jan 2026 wrapper, no pkill block\n";
fs::write(&wrapper, stale).unwrap();
fs::write(wrapper.with_extension("sh.hash"), "deadbe").unwrap();
let binary = wrapper_dir.join("freenet");
ensure_service_file_updated(&binary, true).unwrap();
let new_content = fs::read_to_string(&wrapper).unwrap();
assert_ne!(new_content, stale, "stale wrapper must be replaced");
let expected = super::super::service::generate_wrapper_script(&binary);
assert_eq!(new_content, expected);
let recorded = fs::read_to_string(wrapper.with_extension("sh.hash")).unwrap();
assert!(
is_valid_wrapper_hash(recorded.trim()),
"sidecar must now hold a valid SHA-256, got {recorded:?}"
);
}
#[test]
#[cfg(target_os = "macos")]
fn ensure_service_file_updated_preserves_user_modified_wrapper() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().expect("create tempdir");
let fake_home = tmp.path();
let _home = ScopedHome::new(fake_home);
let launch_agents = fake_home.join("Library/LaunchAgents");
fs::create_dir_all(&launch_agents).unwrap();
fs::write(launch_agents.join("org.freenet.node.plist"), "<plist/>").unwrap();
let wrapper_dir = fake_home.join(".local/bin");
fs::create_dir_all(&wrapper_dir).unwrap();
let wrapper = wrapper_dir.join("freenet-service-wrapper.sh");
let user_edited = "#!/bin/bash\n# I have customized this myself\necho user\n";
fs::write(&wrapper, user_edited).unwrap();
let stale_hash = wrapper_content_hash("something-different");
fs::write(wrapper.with_extension("sh.hash"), format!("{stale_hash}\n")).unwrap();
let binary = wrapper_dir.join("freenet");
ensure_service_file_updated(&binary, true)
.expect("ensure_service_file_updated should succeed even when skipping");
let still_on_disk = fs::read_to_string(&wrapper).unwrap();
assert_eq!(
still_on_disk, user_edited,
"user-modified wrapper must be preserved (sidecar hash mismatch)"
);
}
#[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");
}
#[test]
fn embedded_updater_script_resolves_inside_freenet_crate() {
const SCRIPT: &str = include_str!("../../../scripts/macos-bundle-updater.sh");
assert!(
SCRIPT.contains("macOS DMG-swap updater"),
"include_str! resolved to the wrong file"
);
}
#[test]
fn required_checksum_missing_manifest_is_fail_closed() {
let err = required_checksum(None, "freenet-x86_64-unknown-linux-musl.tar.gz")
.expect_err("a missing manifest must refuse to install (fail-closed)");
assert!(
err.to_string().contains("refusing to install"),
"error must state the install was refused, got: {err}"
);
}
#[test]
fn required_checksum_missing_entry_is_fail_closed() {
let manifest = Checksums::parse("abc123 some-other-asset.tar.gz\n");
let err = required_checksum(Some(&manifest), "freenet-x86_64-unknown-linux-musl.tar.gz")
.expect_err("a missing entry must refuse to install (fail-closed)");
assert!(
err.to_string().contains("refusing to install"),
"error must state the install was refused, got: {err}"
);
}
#[test]
fn required_checksum_present_entry_returns_hash() {
let manifest = Checksums::parse(
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef \
freenet-x86_64-unknown-linux-musl.tar.gz\n",
);
assert_eq!(
required_checksum(Some(&manifest), "freenet-x86_64-unknown-linux-musl.tar.gz").unwrap(),
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
);
}
#[test]
fn verify_checksum_accepts_match_and_rejects_mismatch() {
use std::io::Write as _;
let dir = tempfile::tempdir().expect("create tempdir");
let path = dir.path().join("artifact.bin");
let contents = b"freenet release artifact";
std::fs::File::create(&path)
.unwrap()
.write_all(contents)
.unwrap();
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(contents);
let correct = hasher.finalize().iter().fold(String::new(), |mut s, b| {
use std::fmt::Write as _;
write!(s, "{:02x}", b).unwrap();
s
});
verify_checksum(&path, &correct).expect("matching checksum must pass");
assert!(
verify_checksum(&path, &"0".repeat(64)).is_err(),
"a mismatched checksum must refuse (return Err), never silently pass"
);
}
#[cfg(unix)]
#[test]
fn checksum_refusal_is_retryable_not_a_lockout() {
let exit = Ok(exit_with(1));
assert_eq!(
classify_update_subprocess(&exit),
UpdateSubprocessOutcome::OtherFailure,
"a checksum refusal exits 1, which must classify as OtherFailure"
);
assert_eq!(
update_counter_action(UpdateSubprocessOutcome::OtherFailure),
UpdateCounterAction::NoChange,
"OtherFailure must NOT increment the lockout counter (retry, don't lock out)"
);
}
#[test]
fn checksum_gate_precedes_install_and_records_no_failure() {
const SRC: &str = include_str!("update.rs");
let gate = SRC
.find("required_checksum(checksums.as_ref(), &freenet_asset_name)")
.expect("freenet checksum gate must exist in download_and_install");
let verify = SRC
.find("verify_checksum(&freenet_archive_path")
.expect("freenet archive verify_checksum call must exist");
let install = SRC
.find("replace_binary(&extracted_freenet")
.expect("replace_binary install call must exist");
assert!(
gate < verify && verify < install,
"checksum gate + verify must precede the replace_binary install"
);
assert!(
!SRC[gate..install].contains("record_update_failure"),
"no record_update_failure may run between the checksum gate and \
replace_binary — a checksum refusal must stay a retryable \
OtherFailure, not a MAX_UPDATE_FAILURES lockout"
);
}
#[test]
fn verification_failures_are_classified_transient_are_not() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("artifact");
std::fs::write(&f, b"the actual bytes").unwrap();
let mismatch = verify_checksum(&f, &"0".repeat(64)).unwrap_err();
assert!(
is_release_verification_failure(&mismatch),
"checksum mismatch must be a verification failure"
);
let bad_sig = [7u8; 64];
let sig_err = verify_manifest_signature_with(
b"some manifest",
Some(&bad_sig),
&FREENET_RELEASE_PUBKEY,
false,
true,
)
.unwrap_err();
assert!(
is_release_verification_failure(&sig_err),
"invalid signature must be a verification failure"
);
let short_sig_err = verify_manifest_signature_with(
b"some manifest",
Some(&[1u8, 2, 3]),
&FREENET_RELEASE_PUBKEY,
false,
true,
)
.unwrap_err();
assert!(
is_release_verification_failure(&short_sig_err),
"wrong-length signature must be a verification failure"
);
let transient = anyhow::anyhow!("Failed to download file: connection reset");
assert!(
!is_release_verification_failure(&transient),
"a transient download error must NOT count toward the gate"
);
}
#[test]
fn install_failure_gate_only_counts_verification_failures() {
const SRC: &str = include_str!("update.rs");
let (_, run_async) = SRC
.split_once("let install_result = self.download_and_install(")
.expect("download_and_install call site not found");
let (matchbody, _) = run_async
.split_once("install_result.map(|_| ())")
.expect("end of install-result match not found");
assert!(
matchbody.contains(
"Ok(InstallOutcome::Installed) => super::rollback::clear_install_failures()"
),
"the Installed outcome must clear the install-failure gate"
);
assert!(
matchbody.contains("Err(e) if is_release_verification_failure(e) =>")
&& matchbody.contains("record_install_failure"),
"a verification-failure Err must record toward the gate"
);
let (_, after_catch) = matchbody
.split_once("// Transient install error: neither record nor clear")
.expect("transient Err arm must be present and documented");
let catch_body = &after_catch[..after_catch.find('}').unwrap_or(after_catch.len())];
assert!(
!catch_body.contains("record_install_failure")
&& !catch_body.contains("clear_install_failures"),
"the transient Err arm must neither record nor clear the gate"
);
}
fn test_signing_key(seed: u8) -> (ed25519_dalek::SigningKey, [u8; 32]) {
let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]);
let vk = sk.verifying_key().to_bytes();
(sk, vk)
}
fn dalek_sign(sk: &ed25519_dalek::SigningKey, msg: &[u8]) -> [u8; 64] {
use ed25519_dalek::Signer;
sk.sign(msg).to_bytes()
}
const SAMPLE_MANIFEST: &[u8] =
b"abc123 freenet-x86_64-unknown-linux-musl.tar.gz\ndef456 fdev-x86_64-unknown-linux-musl.tar.gz\n";
#[test]
fn release_pubkey_matches_published_hex() {
let expected =
hex::decode("eb2986604e34a3f985279a7e70852f3764d3347fe82074d92b1e4bc6336f8664")
.unwrap();
assert_eq!(FREENET_RELEASE_PUBKEY.as_slice(), expected.as_slice());
ed25519_dalek::VerifyingKey::from_bytes(&FREENET_RELEASE_PUBKEY)
.expect("baked-in release public key must be a valid ed25519 point");
}
#[test]
fn revocation_pubkey_matches_published_hex() {
let expected =
hex::decode("c93f086b6be206867b65a05592a22146ea72147270d1b356bc368455d13d3722")
.unwrap();
assert_eq!(FREENET_REVOCATION_PUBKEY.as_slice(), expected.as_slice());
ed25519_dalek::VerifyingKey::from_bytes(&FREENET_REVOCATION_PUBKEY)
.expect("baked-in revocation public key must be a valid ed25519 point");
}
#[test]
fn transition_flag_is_false_until_signed_floor_established() {
assert!(
!REQUIRE_RELEASE_SIGNATURE,
"do not require signatures until the signed floor is established; \
see the two-release transition note on REQUIRE_RELEASE_SIGNATURE"
);
}
#[test]
fn valid_signature_accepted() {
let (sk, vk) = test_signing_key(7);
let sig = dalek_sign(&sk, SAMPLE_MANIFEST);
verify_manifest_signature_with(SAMPLE_MANIFEST, Some(&sig), &vk, false, true)
.expect("a valid signature over the manifest must be accepted");
verify_manifest_signature_with(SAMPLE_MANIFEST, Some(&sig), &vk, true, true)
.expect("a valid signature must be accepted even when required");
}
#[test]
fn tampered_manifest_rejected() {
let (sk, vk) = test_signing_key(7);
let sig = dalek_sign(&sk, SAMPLE_MANIFEST);
let mut tampered = SAMPLE_MANIFEST.to_vec();
tampered[0] ^= 0x01; let err = verify_manifest_signature_with(&tampered, Some(&sig), &vk, false, true)
.expect_err("a signature over different bytes must be refused (fail-closed)");
assert!(
err.to_string().contains("refusing to install"),
"tamper rejection must refuse the install, got: {err}"
);
}
#[test]
fn wrong_key_signature_rejected() {
let (sk_a, _vk_a) = test_signing_key(1);
let (_sk_b, vk_b) = test_signing_key(2);
let sig = dalek_sign(&sk_a, SAMPLE_MANIFEST);
let err = verify_manifest_signature_with(SAMPLE_MANIFEST, Some(&sig), &vk_b, false, true)
.expect_err("a signature from an untrusted key must be refused (fail-closed)");
assert!(err.to_string().contains("refusing to install"));
}
#[test]
fn malformed_signature_length_rejected() {
let (_sk, vk) = test_signing_key(3);
let short = [0u8; 63];
let err = verify_manifest_signature_with(SAMPLE_MANIFEST, Some(&short), &vk, false, true)
.expect_err("a wrong-length signature must be refused");
assert!(err.to_string().contains("wrong length"));
}
#[test]
fn absent_signature_allowed_when_not_required() {
verify_manifest_signature_with(SAMPLE_MANIFEST, None, &FREENET_RELEASE_PUBKEY, false, true)
.expect("absent signature must be allowed while not required");
}
#[test]
fn absent_signature_refused_when_required() {
let err = verify_manifest_signature_with(
SAMPLE_MANIFEST,
None,
&FREENET_RELEASE_PUBKEY,
true,
true,
)
.expect_err("absent signature must be refused once signatures are required");
assert!(err.to_string().contains("refusing to install"));
}
#[test]
fn openssl_fixture_verifies_via_dalek() {
const FIXTURE_MSG: &[u8] = b"freenet-release-interop-fixture-v1\n";
let pubkey: [u8; 32] =
hex::decode("998007f01e8bd34d736017398822ce214bf78b81a2015e2e5e8de94d9c0cd2ae")
.unwrap()
.try_into()
.unwrap();
let sig =
hex::decode("062a3ae0db73cc41fcd73f2cbb29eb0cf0cdd9420bb49db7d204ddec8803e81a5aa653b68c13105fd12ee996e8a9ba5eb553ec310133c6913756dbef437b1f0a")
.unwrap();
verify_manifest_signature_with(FIXTURE_MSG, Some(&sig), &pubkey, true, true)
.expect("openssl-produced raw ed25519 signature must verify via the dalek path");
let mut tampered = FIXTURE_MSG.to_vec();
tampered.push(b'!');
assert!(
verify_manifest_signature_with(&tampered, Some(&sig), &pubkey, true, true).is_err(),
"altering the fixture message must refuse (fail-closed)"
);
}
#[test]
fn interop_openssl_sign_dalek_verify() {
use std::process::Command;
let openssl_ok = Command::new("openssl")
.arg("version")
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false);
if !openssl_ok {
eprintln!("skipping interop_openssl_sign_dalek_verify: openssl not available");
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let key_pem = dir.path().join("key.pem");
let msg = dir.path().join("SHA256SUMS.txt");
let sig = dir.path().join("SHA256SUMS.txt.sig");
let pub_der = dir.path().join("pub.der");
std::fs::write(&msg, SAMPLE_MANIFEST).unwrap();
let genkey = Command::new("openssl")
.args(["genpkey", "-algorithm", "ed25519", "-out"])
.arg(&key_pem)
.output()
.expect("spawn openssl genpkey");
if !genkey.status.success() {
eprintln!(
"skipping interop_openssl_sign_dalek_verify: openssl genpkey ed25519 unsupported"
);
return;
}
let sign = Command::new("openssl")
.arg("pkeyutl")
.arg("-sign")
.arg("-inkey")
.arg(&key_pem)
.arg("-rawin")
.arg("-in")
.arg(&msg)
.arg("-out")
.arg(&sig)
.output()
.expect("spawn openssl pkeyutl sign");
if !sign.status.success() {
eprintln!(
"skipping interop_openssl_sign_dalek_verify: openssl raw ed25519 sign unsupported \
(needs openssl >= 3.0)"
);
return;
}
let pubout = Command::new("openssl")
.args(["pkey", "-pubout", "-outform", "DER", "-in"])
.arg(&key_pem)
.arg("-out")
.arg(&pub_der)
.output()
.expect("spawn openssl pkey pubout");
assert!(pubout.status.success(), "openssl pkey -pubout must succeed");
let der = std::fs::read(&pub_der).unwrap();
assert!(der.len() >= 32, "DER SPKI must contain a 32-byte key");
let pubkey: [u8; 32] = der[der.len() - 32..].try_into().unwrap();
let sig_bytes = std::fs::read(&sig).unwrap();
assert_eq!(
sig_bytes.len(),
64,
"openssl raw ed25519 signature must be exactly 64 bytes"
);
verify_manifest_signature_with(SAMPLE_MANIFEST, Some(&sig_bytes), &pubkey, true, true)
.expect("openssl-signed manifest must verify through the binary's dalek path");
let mut tampered = SAMPLE_MANIFEST.to_vec();
tampered[0] ^= 0x01;
assert!(
verify_manifest_signature_with(&tampered, Some(&sig_bytes), &pubkey, true, true)
.is_err(),
"a tampered manifest must be refused even with a valid openssl signature"
);
}
}