use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const POST_STOP_EXIT_CODE_ENV_VAR: &str = "FREENET_POST_STOP_EXIT_CODE";
pub(crate) const ROLLBACK_CRASH_THRESHOLD: u32 = 3;
const _: () = assert!(
ROLLBACK_CRASH_THRESHOLD < 5,
"ROLLBACK_CRASH_THRESHOLD must be < systemd StartLimitBurst (5) in service/linux.rs"
);
const _: () = assert!(
ROLLBACK_CRASH_THRESHOLD < 50,
"ROLLBACK_CRASH_THRESHOLD must be < WRAPPER_MAX_CONSECUTIVE_FAILURES (50) in service.rs"
);
pub(crate) const COMMIT_HEALTHY_UPTIME_SECS: u64 = 60;
pub(crate) const PROBATION_MAX_AGE_SECS: u64 = 3600;
const PROBATION_FILE: &str = "update_probation.json";
const KNOWN_BAD_FILE: &str = "known_bad_version";
const KNOWN_GOOD_BINARY_FILE: &str = "known_good_binary";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ProbationState {
pub new_version: String,
pub previous_version: String,
pub rollback_binary: PathBuf,
pub target_binary: PathBuf,
pub rollback_size: u64,
pub rollback_sha256: String,
pub installed_at_unix: u64,
pub crash_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KnownGoodMeta {
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StopClass {
Crash,
NotCrash,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PostStopOutcome {
Proceed,
CrashRecorded { crash_count: u32 },
RolledBack {
restored_version: String,
bad_version: String,
},
RollbackUnavailable { reason: String },
}
fn state_dir() -> Option<PathBuf> {
super::auto_update::state_dir()
}
fn fsync_dir(dir: &Path) {
if let Ok(f) = std::fs::File::open(dir) {
let _sync = f.sync_all();
}
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let dir = path.parent().context("path has no parent directory")?;
std::fs::create_dir_all(dir).ok();
let file_name = path
.file_name()
.context("path has no file name")?
.to_string_lossy();
let tmp = dir.join(format!(".{file_name}.tmp"));
{
let mut f = std::fs::File::create(&tmp).context("create temp for atomic write")?;
f.write_all(bytes).context("write temp for atomic write")?;
f.sync_all().context("fsync temp for atomic write")?;
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _rm = std::fs::remove_file(&tmp);
return Err(e).context("rename temp into place");
}
fsync_dir(dir);
Ok(())
}
fn sha256_file(path: &Path) -> Result<(u64, String)> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = std::fs::File::open(path).context("open file for hashing")?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
let mut size: u64 = 0;
loop {
let n = file.read(&mut buf).context("read file for hashing")?;
if n == 0 {
break;
}
size += n as u64;
hasher.update(&buf[..n]);
}
let hex = hasher
.finalize()
.iter()
.fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write;
write!(s, "{b:02x}").expect("writing to String is infallible");
s
});
Ok((size, hex))
}
pub(crate) fn capture_known_good_at(dir: &Path, current_binary: &Path) -> Result<KnownGoodMeta> {
use std::io::Write;
std::fs::create_dir_all(dir).context("failed to create state directory")?;
let dest = dir.join(KNOWN_GOOD_BINARY_FILE);
let tmp = dir.join(format!("{KNOWN_GOOD_BINARY_FILE}.tmp"));
{
let mut src = std::fs::File::open(current_binary).context("open current binary")?;
let mut dst = std::fs::File::create(&tmp).context("create known-good temp")?;
std::io::copy(&mut src, &mut dst).context("copy known-good binary")?;
dst.flush().ok();
dst.sync_all().context("fsync known-good temp")?;
}
set_executable(&tmp);
if let Err(e) = std::fs::rename(&tmp, &dest) {
let _rm = std::fs::remove_file(&tmp);
return Err(e).context("install known-good binary snapshot");
}
fsync_dir(dir);
let (size, sha256) = sha256_file(&dest).context("hash known-good snapshot")?;
Ok(KnownGoodMeta { size, sha256 })
}
pub(crate) fn prepare_known_good_for_install(
current_version: &str,
current_binary: &Path,
) -> Result<(KnownGoodMeta, String)> {
let dir = state_dir().context("could not resolve state directory")?;
prepare_known_good_for_install_at(&dir, current_version, current_binary)
}
pub(crate) fn prepare_known_good_for_install_at(
dir: &Path,
current_version: &str,
current_binary: &Path,
) -> Result<(KnownGoodMeta, String)> {
if let Some(existing) = read_probation_at(dir) {
let fresh_enough =
now_unix().saturating_sub(existing.installed_at_unix) <= PROBATION_MAX_AGE_SECS;
if existing.new_version == current_version
&& fresh_enough
&& dir.join(KNOWN_GOOD_BINARY_FILE).exists()
{
return Ok((
KnownGoodMeta {
size: existing.rollback_size,
sha256: existing.rollback_sha256,
},
existing.previous_version,
));
}
}
let meta = capture_known_good_at(dir, current_binary)?;
Ok((meta, current_version.to_string()))
}
pub(crate) fn begin_probation(
new_version: &str,
previous_version: &str,
target_binary: &Path,
meta: &KnownGoodMeta,
) -> Result<()> {
match state_dir() {
Some(dir) => begin_probation_at(
dir.as_path(),
new_version,
previous_version,
target_binary,
meta,
),
None => Ok(()),
}
}
pub(crate) fn begin_probation_at(
dir: &Path,
new_version: &str,
previous_version: &str,
target_binary: &Path,
meta: &KnownGoodMeta,
) -> Result<()> {
let _mkdir = std::fs::create_dir_all(dir);
let state = ProbationState {
new_version: new_version.to_string(),
previous_version: previous_version.to_string(),
rollback_binary: dir.join(KNOWN_GOOD_BINARY_FILE),
target_binary: target_binary.to_path_buf(),
rollback_size: meta.size,
rollback_sha256: meta.sha256.clone(),
installed_at_unix: now_unix(),
crash_count: 0,
};
clear_known_bad_at(dir);
write_probation_at(dir, &state)
.context("failed to write crash-loop probation marker; the installed version has no rollback protection")
}
pub(crate) fn read_probation() -> Option<ProbationState> {
read_probation_at(state_dir()?.as_path())
}
pub(crate) fn read_probation_at(dir: &Path) -> Option<ProbationState> {
let raw = std::fs::read_to_string(dir.join(PROBATION_FILE)).ok()?;
serde_json::from_str(&raw).ok()
}
fn write_probation_at(dir: &Path, state: &ProbationState) -> Result<()> {
let raw = serde_json::to_vec(state).context("serialize probation marker")?;
atomic_write(&dir.join(PROBATION_FILE), &raw)
}
fn remove_probation_at(dir: &Path) {
let _rm = std::fs::remove_file(dir.join(PROBATION_FILE));
}
pub fn commit_probation(current_version: &str) {
if let Some(dir) = state_dir() {
match commit_probation_at(dir.as_path(), current_version) {
CommitOutcome::Committed => tracing::info!(
version = current_version,
"Auto-update probation passed: new version ran healthily for \
{COMMIT_HEALTHY_UPTIME_SECS}s; committing (rollback disarmed)."
),
CommitOutcome::ClearedStale { marker_version } => tracing::debug!(
running = current_version,
marker = %marker_version,
"Cleared stale auto-update probation marker for a different version."
),
CommitOutcome::Nothing => {}
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CommitOutcome {
Committed,
ClearedStale { marker_version: String },
Nothing,
}
pub(crate) fn commit_probation_at(dir: &Path, current_version: &str) -> CommitOutcome {
match read_probation_at(dir) {
Some(state) if state.new_version == current_version => {
remove_probation_at(dir);
CommitOutcome::Committed
}
Some(state) => {
remove_probation_at(dir);
CommitOutcome::ClearedStale {
marker_version: state.new_version,
}
}
None => CommitOutcome::Nothing,
}
}
pub(crate) fn pin_known_bad_at(dir: &Path, version: &str) -> Result<()> {
atomic_write(&dir.join(KNOWN_BAD_FILE), format!("{version}\n").as_bytes())
}
pub(crate) fn read_known_bad_at(dir: &Path) -> Option<String> {
let raw = std::fs::read_to_string(dir.join(KNOWN_BAD_FILE)).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn clear_known_bad_at(dir: &Path) {
let _rm = std::fs::remove_file(dir.join(KNOWN_BAD_FILE));
}
pub fn is_version_pinned_bad(version: &str) -> bool {
state_dir()
.map(|d| is_version_pinned_bad_at(d.as_path(), version))
.unwrap_or(false)
}
pub(crate) fn is_version_pinned_bad_at(dir: &Path, version: &str) -> bool {
read_known_bad_at(dir).as_deref() == Some(version)
}
pub(crate) const INSTALL_FAILURE_GATE_THRESHOLD: u32 = 3;
const INSTALL_FAILURES_FILE: &str = "install_failures.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct InstallFailureState {
pub version: String,
pub count: u32,
}
pub(crate) fn read_install_failures_at(dir: &Path) -> Option<InstallFailureState> {
let raw = std::fs::read_to_string(dir.join(INSTALL_FAILURES_FILE)).ok()?;
serde_json::from_str(&raw).ok()
}
fn write_install_failures_at(dir: &Path, state: &InstallFailureState) -> Result<()> {
let raw = serde_json::to_vec(state).context("serialize install-failure state")?;
atomic_write(&dir.join(INSTALL_FAILURES_FILE), &raw)
}
fn clear_install_failures_at(dir: &Path) {
let _rm = std::fs::remove_file(dir.join(INSTALL_FAILURES_FILE));
}
pub(crate) fn record_install_failure_at(dir: &Path, version: &str) {
let next = match read_install_failures_at(dir) {
Some(prev) if prev.version == version => InstallFailureState {
version: version.to_string(),
count: prev.count.saturating_add(1),
},
_ => InstallFailureState {
version: version.to_string(),
count: 1,
},
};
if let Err(e) = write_install_failures_at(dir, &next) {
tracing::warn!(
version,
error = %e,
"Failed to persist per-version install-failure counter (#4073)"
);
}
}
pub fn record_install_failure(version: &str) {
if let Some(dir) = state_dir() {
record_install_failure_at(&dir, version);
}
}
pub fn clear_install_failures() {
if let Some(dir) = state_dir() {
clear_install_failures_at(&dir);
}
}
pub(crate) fn is_version_install_gated_at(dir: &Path, version: &str) -> bool {
match read_install_failures_at(dir) {
Some(state) => state.version == version && state.count >= INSTALL_FAILURE_GATE_THRESHOLD,
None => false,
}
}
pub fn is_version_install_gated(version: &str) -> bool {
state_dir()
.map(|d| is_version_install_gated_at(d.as_path(), version))
.unwrap_or(false)
}
pub fn post_stop_status_from_env() -> Option<String> {
let raw = std::env::var(POST_STOP_EXIT_CODE_ENV_VAR).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn classify_stop(status: &str) -> StopClass {
match status.trim() {
"0" | "2" | "43" | "44" => StopClass::NotCrash,
"42" => StopClass::NotCrash,
_ => StopClass::Crash,
}
}
pub(crate) fn handle_post_stop(status: &str, current_version: &str) -> PostStopOutcome {
match state_dir() {
Some(dir) => handle_post_stop_at(dir.as_path(), status, current_version),
None => PostStopOutcome::Proceed,
}
}
pub(crate) fn handle_post_stop_at(
dir: &Path,
status: &str,
current_version: &str,
) -> PostStopOutcome {
if classify_stop(status) == StopClass::NotCrash {
return PostStopOutcome::Proceed;
}
let Some(mut state) = read_probation_at(dir) else {
return PostStopOutcome::Proceed;
};
if state.new_version != current_version {
remove_probation_at(dir);
return PostStopOutcome::Proceed;
}
if now_unix().saturating_sub(state.installed_at_unix) > PROBATION_MAX_AGE_SECS {
remove_probation_at(dir);
return PostStopOutcome::Proceed;
}
state.crash_count = state.crash_count.saturating_add(1);
let persisted = write_probation_at(dir, &state).is_ok();
if !should_rollback(state.crash_count) {
if !persisted {
return PostStopOutcome::RollbackUnavailable {
reason: "cannot persist probation crash count (state dir full/unwritable); \
crashes cannot accumulate toward rollback"
.to_string(),
};
}
return PostStopOutcome::CrashRecorded {
crash_count: state.crash_count,
};
}
if !state.rollback_binary.exists() {
remove_probation_at(dir);
return PostStopOutcome::RollbackUnavailable {
reason: format!(
"no retained known-good binary at {}",
state.rollback_binary.display()
),
};
}
match sha256_file(&state.rollback_binary) {
Ok((size, hash)) if size == state.rollback_size && hash == state.rollback_sha256 => {}
Ok((size, _hash)) => {
remove_probation_at(dir);
return PostStopOutcome::RollbackUnavailable {
reason: format!(
"known-good integrity check failed (size {size} vs expected {}, or SHA-256 \
mismatch); leaving the running binary in place",
state.rollback_size
),
};
}
Err(e) => {
return PostStopOutcome::RollbackUnavailable {
reason: format!("cannot read known-good binary to verify: {e:#}"),
};
}
}
if let Err(e) = pin_known_bad_at(dir, &state.new_version) {
return PostStopOutcome::RollbackUnavailable {
reason: format!("cannot persist known-bad pin (not restoring to avoid a loop): {e:#}"),
};
}
match restore_binary(&state.rollback_binary, &state.target_binary) {
Ok(()) => {
remove_probation_at(dir);
PostStopOutcome::RolledBack {
restored_version: state.previous_version.clone(),
bad_version: state.new_version.clone(),
}
}
Err(e) => {
PostStopOutcome::RollbackUnavailable {
reason: format!("restore failed: {e:#}"),
}
}
}
}
pub(crate) fn should_rollback(crash_count: u32) -> bool {
crash_count >= ROLLBACK_CRASH_THRESHOLD
}
fn restore_binary(src: &Path, target: &Path) -> Result<()> {
let parent = target
.parent()
.context("target binary has no parent directory")?;
let stem = target.file_name().unwrap_or_default().to_string_lossy();
let temp = parent.join(format!(".{stem}.rollback.tmp"));
{
use std::io::Write;
let mut s = std::fs::File::open(src).context("open known-good binary")?;
let mut t = std::fs::File::create(&temp).context("create rollback temp")?;
std::io::copy(&mut s, &mut t).context("copy known-good binary into place")?;
t.flush().ok();
t.sync_all().context("fsync rollback temp")?;
}
set_executable(&temp);
#[cfg(not(windows))]
{
if let Err(e) = std::fs::rename(&temp, target) {
let _rm = std::fs::remove_file(&temp);
return Err(e).context("atomically install rolled-back binary");
}
fsync_dir(parent);
Ok(())
}
#[cfg(windows)]
{
let displaced = parent.join(format!(".{stem}.badver"));
if displaced.exists() {
let _rm = std::fs::remove_file(&displaced);
}
if target.exists() {
std::fs::rename(target, &displaced).context("move current binary aside")?;
}
if let Err(e) = std::fs::rename(&temp, target) {
if std::fs::rename(&displaced, target).is_err() {
tracing::error!(
target = %target.display(),
displaced = %displaced.display(),
"CRITICAL: rollback failed AND could not restore the displaced binary; \
the previous binary remains at the displaced path for manual recovery"
);
}
let _rm = std::fs::remove_file(&temp);
return Err(e).context("install rolled-back binary");
}
let _rm = std::fs::remove_file(&displaced);
Ok(())
}
}
fn set_executable(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _chmod = std::fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
{
let _ = path;
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_dummy_binary(path: &Path, contents: &[u8]) {
std::fs::write(path, contents).unwrap();
set_executable(path);
}
fn known_good_path(dir: &Path) -> PathBuf {
dir.join(KNOWN_GOOD_BINARY_FILE)
}
fn setup_probation(dir: &Path, new_version: &str, prev_version: &str) -> PathBuf {
let bin_dir = dir.join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let live = bin_dir.join("freenet");
write_dummy_binary(&live, b"GOOD-BINARY");
let meta = capture_known_good_at(dir, &live).unwrap();
write_dummy_binary(&live, b"BAD-BINARY");
begin_probation_at(dir, new_version, prev_version, &live, &meta).unwrap();
live
}
#[test]
fn classify_stop_crash_vs_not() {
for s in ["0", "2", "42", "43", "44", " 42 ", "0\n"] {
assert_eq!(classify_stop(s), StopClass::NotCrash, "status {s:?}");
}
for s in [
"45", "1", "101", "134", "137", "139", "SEGV", "ABRT", "KILL", "garbage",
] {
assert_eq!(classify_stop(s), StopClass::Crash, "status {s:?}");
}
}
#[test]
fn probation_roundtrip_and_commit() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
let state = read_probation_at(dir).expect("probation present");
assert_eq!(state.new_version, "0.2.84");
assert_eq!(state.previous_version, "0.2.83");
assert_eq!(state.crash_count, 0);
assert_eq!(state.target_binary, live);
assert_eq!(state.rollback_size, "GOOD-BINARY".len() as u64);
assert_eq!(state.rollback_sha256.len(), 64);
assert_eq!(commit_probation_at(dir, "0.2.84"), CommitOutcome::Committed);
assert!(read_probation_at(dir).is_none());
assert_eq!(commit_probation_at(dir, "0.2.84"), CommitOutcome::Nothing);
}
#[test]
fn commit_clears_stale_marker_for_other_version() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83");
let CommitOutcome::ClearedStale { marker_version } = commit_probation_at(dir, "0.2.85")
else {
panic!("expected ClearedStale");
};
assert_eq!(marker_version, "0.2.84");
assert!(read_probation_at(dir).is_none());
}
#[test]
fn crash_during_probation_records_then_rolls_back_and_pins() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
for expected in 1..ROLLBACK_CRASH_THRESHOLD {
let PostStopOutcome::CrashRecorded { crash_count } =
handle_post_stop_at(dir, "101", "0.2.84")
else {
panic!("expected CrashRecorded({expected})");
};
assert_eq!(crash_count, expected);
assert_eq!(std::fs::read(&live).unwrap(), b"BAD-BINARY");
assert!(read_known_bad_at(dir).is_none());
assert!(read_probation_at(dir).is_some());
}
let PostStopOutcome::RolledBack {
restored_version,
bad_version,
} = handle_post_stop_at(dir, "SEGV", "0.2.84")
else {
panic!("expected RolledBack");
};
assert_eq!(restored_version, "0.2.83");
assert_eq!(bad_version, "0.2.84");
assert_eq!(std::fs::read(&live).unwrap(), b"GOOD-BINARY");
assert!(is_version_pinned_bad_at(dir, "0.2.84"));
assert!(read_probation_at(dir).is_none());
assert!(known_good_path(dir).exists());
}
#[test]
fn voluntary_update_exit_42_during_probation_is_not_a_crash() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83");
for _ in 0..ROLLBACK_CRASH_THRESHOLD + 2 {
assert_eq!(
handle_post_stop_at(dir, "42", "0.2.84"),
PostStopOutcome::Proceed
);
let state = read_probation_at(dir).expect("marker preserved");
assert_eq!(
state.crash_count, 0,
"exit 42 must not increment crash_count"
);
}
}
#[test]
fn clean_exit_codes_during_probation_are_not_crashes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83");
for s in ["0", "43"] {
assert_eq!(
handle_post_stop_at(dir, s, "0.2.84"),
PostStopOutcome::Proceed
);
assert_eq!(read_probation_at(dir).unwrap().crash_count, 0);
}
}
#[test]
fn pinned_version_is_not_reapplied_but_newer_is_allowed() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
pin_known_bad_at(dir, "0.2.84").unwrap();
assert!(is_version_pinned_bad_at(dir, "0.2.84"));
assert!(!is_version_pinned_bad_at(dir, "0.2.85"));
assert!(!is_version_pinned_bad_at(dir, "0.2.83"));
let live = dir.join("freenet");
write_dummy_binary(&live, b"NEWER");
let meta = KnownGoodMeta {
size: 5,
sha256: "x".repeat(64),
};
begin_probation_at(dir, "0.2.85", "0.2.83", &live, &meta).unwrap();
assert!(!is_version_pinned_bad_at(dir, "0.2.84"));
}
#[test]
fn rollback_is_bounded_to_one_generation() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83");
for _ in 0..ROLLBACK_CRASH_THRESHOLD {
handle_post_stop_at(dir, "101", "0.2.84");
}
assert!(read_probation_at(dir).is_none());
assert_eq!(
handle_post_stop_at(dir, "101", "0.2.83"),
PostStopOutcome::Proceed
);
}
#[test]
fn no_probation_means_proceed() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
handle_post_stop_at(tmp.path(), "101", "0.2.84"),
PostStopOutcome::Proceed
);
}
#[test]
fn stale_marker_for_other_version_proceeds_and_is_cleared() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83");
assert_eq!(
handle_post_stop_at(dir, "101", "0.2.99"),
PostStopOutcome::Proceed
);
assert!(read_probation_at(dir).is_none());
}
#[test]
fn aged_out_marker_is_treated_as_committed() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
let mut state = read_probation_at(dir).unwrap();
state.installed_at_unix = now_unix().saturating_sub(PROBATION_MAX_AGE_SECS + 60);
write_probation_at(dir, &state).unwrap();
assert_eq!(
handle_post_stop_at(dir, "101", "0.2.84"),
PostStopOutcome::Proceed
);
assert!(read_probation_at(dir).is_none(), "stale marker cleared");
assert_eq!(std::fs::read(&live).unwrap(), b"BAD-BINARY", "no rollback");
}
#[test]
fn corrupt_known_good_blob_does_not_brick() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
std::fs::write(known_good_path(dir), b"TRUNC").unwrap();
for _ in 0..ROLLBACK_CRASH_THRESHOLD - 1 {
handle_post_stop_at(dir, "101", "0.2.84");
}
let PostStopOutcome::RollbackUnavailable { reason } =
handle_post_stop_at(dir, "101", "0.2.84")
else {
panic!("expected RollbackUnavailable");
};
assert!(reason.contains("integrity"), "reason: {reason}");
assert_eq!(std::fs::read(&live).unwrap(), b"BAD-BINARY");
assert!(!is_version_pinned_bad_at(dir, "0.2.84"));
assert!(read_probation_at(dir).is_none());
}
#[test]
fn pin_write_failure_aborts_rollback_without_restoring() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
std::fs::create_dir(dir.join(KNOWN_BAD_FILE)).unwrap();
for _ in 0..ROLLBACK_CRASH_THRESHOLD - 1 {
handle_post_stop_at(dir, "101", "0.2.84");
}
let PostStopOutcome::RollbackUnavailable { reason } =
handle_post_stop_at(dir, "101", "0.2.84")
else {
panic!("expected RollbackUnavailable");
};
assert!(reason.contains("pin"), "reason: {reason}");
assert_eq!(std::fs::read(&live).unwrap(), b"BAD-BINARY");
assert!(read_probation_at(dir).is_some());
}
#[test]
fn rollback_unavailable_when_known_good_missing() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
std::fs::remove_file(known_good_path(dir)).unwrap();
for _ in 0..ROLLBACK_CRASH_THRESHOLD - 1 {
handle_post_stop_at(dir, "101", "0.2.84");
}
assert!(matches!(
handle_post_stop_at(dir, "101", "0.2.84"),
PostStopOutcome::RollbackUnavailable { .. }
));
assert_eq!(std::fs::read(&live).unwrap(), b"BAD-BINARY");
assert!(read_probation_at(dir).is_none());
}
#[test]
fn prepare_known_good_preserves_blob_and_label_for_genuine_chained_install() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83"); let blob_before = std::fs::read(known_good_path(dir)).unwrap();
let existing = read_probation_at(dir).unwrap();
let live = dir.join("bin").join("freenet");
let (meta, previous) = prepare_known_good_for_install_at(dir, "0.2.84", &live).unwrap();
assert_eq!(previous, "0.2.83");
assert_eq!(std::fs::read(known_good_path(dir)).unwrap(), blob_before);
assert_eq!(meta.size, existing.rollback_size);
assert_eq!(meta.sha256, existing.rollback_sha256);
}
#[test]
fn prepare_known_good_recaptures_when_blob_externally_deleted() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
std::fs::remove_file(known_good_path(dir)).unwrap();
let (meta, previous) = prepare_known_good_for_install_at(dir, "0.2.84", &live).unwrap();
assert_eq!(previous, "0.2.84");
assert!(known_good_path(dir).exists());
assert_eq!(
std::fs::read(known_good_path(dir)).unwrap(),
std::fs::read(&live).unwrap()
);
let (size, hash) = sha256_file(&known_good_path(dir)).unwrap();
assert_eq!(meta.size, size);
assert_eq!(meta.sha256, hash);
}
#[test]
fn prepare_known_good_recaptures_when_marker_aged_out() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let live = setup_probation(dir, "0.2.84", "0.2.83");
let mut state = read_probation_at(dir).unwrap();
state.installed_at_unix = now_unix().saturating_sub(PROBATION_MAX_AGE_SECS + 60);
write_probation_at(dir, &state).unwrap();
let (meta, previous) = prepare_known_good_for_install_at(dir, "0.2.84", &live).unwrap();
assert_eq!(previous, "0.2.84");
assert_eq!(
std::fs::read(known_good_path(dir)).unwrap(),
std::fs::read(&live).unwrap()
);
let (size, hash) = sha256_file(&known_good_path(dir)).unwrap();
assert_eq!(meta.size, size);
assert_eq!(meta.sha256, hash);
}
#[test]
fn prepare_known_good_recaptures_for_stale_marker_other_version() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83"); let live = dir.join("freenet2");
write_dummy_binary(&live, b"V88-CONTENT");
let (meta, previous) = prepare_known_good_for_install_at(dir, "0.2.88", &live).unwrap();
assert_eq!(previous, "0.2.88");
assert_eq!(std::fs::read(known_good_path(dir)).unwrap(), b"V88-CONTENT");
let (size, hash) = sha256_file(&known_good_path(dir)).unwrap();
assert_eq!(meta.size, size);
assert_eq!(meta.sha256, hash);
}
#[cfg(unix)]
#[test]
fn crash_count_write_failure_is_rollback_unavailable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
setup_probation(dir, "0.2.84", "0.2.83");
let orig = std::fs::metadata(dir).unwrap().permissions();
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let outcome = handle_post_stop_at(dir, "101", "0.2.84");
std::fs::set_permissions(dir, orig).unwrap();
let PostStopOutcome::RollbackUnavailable { reason } = outcome else {
panic!("expected RollbackUnavailable, got {outcome:?}");
};
assert!(reason.contains("persist"), "reason: {reason}");
}
#[test]
fn restore_binary_preserves_source_and_replaces_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let src = dir.join("good");
let target = dir.join("live");
write_dummy_binary(&src, b"SRC-GOOD");
write_dummy_binary(&target, b"TARGET-BAD");
restore_binary(&src, &target).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"SRC-GOOD");
assert_eq!(std::fs::read(&src).unwrap(), b"SRC-GOOD");
}
#[test]
fn should_rollback_threshold() {
assert!(!should_rollback(0));
assert!(!should_rollback(ROLLBACK_CRASH_THRESHOLD - 1));
assert!(should_rollback(ROLLBACK_CRASH_THRESHOLD));
assert!(should_rollback(ROLLBACK_CRASH_THRESHOLD + 1));
}
#[test]
fn corrupt_probation_marker_reads_as_none() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join(PROBATION_FILE), "{not valid json").unwrap();
assert!(read_probation_at(dir).is_none());
assert_eq!(
handle_post_stop_at(dir, "101", "0.2.84"),
PostStopOutcome::Proceed
);
}
#[test]
fn install_gate_engages_after_threshold_failures_of_same_version() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for n in 1..INSTALL_FAILURE_GATE_THRESHOLD {
record_install_failure_at(dir, "0.2.90");
assert!(
!is_version_install_gated_at(dir, "0.2.90"),
"below threshold ({n}) must not gate yet"
);
}
record_install_failure_at(dir, "0.2.90");
assert!(
is_version_install_gated_at(dir, "0.2.90"),
"threshold reached: version must be gated"
);
}
#[test]
fn install_gate_allows_strictly_newer_version() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for _ in 0..INSTALL_FAILURE_GATE_THRESHOLD {
record_install_failure_at(dir, "0.2.90");
}
assert!(is_version_install_gated_at(dir, "0.2.90"));
assert!(
!is_version_install_gated_at(dir, "0.2.91"),
"a newer version must not be gated by an older version's failures"
);
}
#[test]
fn install_gate_resets_when_target_version_changes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for _ in 0..INSTALL_FAILURE_GATE_THRESHOLD {
record_install_failure_at(dir, "0.2.90");
}
assert!(is_version_install_gated_at(dir, "0.2.90"));
record_install_failure_at(dir, "0.2.91");
let state = read_install_failures_at(dir).unwrap();
assert_eq!(state.version, "0.2.91");
assert_eq!(state.count, 1, "new target starts a fresh count");
assert!(!is_version_install_gated_at(dir, "0.2.91"));
assert!(!is_version_install_gated_at(dir, "0.2.90"));
}
#[test]
fn install_gate_cleared_on_success() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for _ in 0..INSTALL_FAILURE_GATE_THRESHOLD {
record_install_failure_at(dir, "0.2.90");
}
assert!(is_version_install_gated_at(dir, "0.2.90"));
clear_install_failures_at(dir);
assert!(read_install_failures_at(dir).is_none());
assert!(!is_version_install_gated_at(dir, "0.2.90"));
clear_install_failures_at(dir);
assert!(!is_version_install_gated_at(dir, "0.2.90"));
}
#[test]
fn install_gate_degrades_safe_on_missing_or_corrupt() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert!(!is_version_install_gated_at(dir, "0.2.90"));
assert!(read_install_failures_at(dir).is_none());
std::fs::write(dir.join(INSTALL_FAILURES_FILE), "{not valid json").unwrap();
assert!(read_install_failures_at(dir).is_none());
assert!(!is_version_install_gated_at(dir, "0.2.90"));
}
#[test]
fn install_gate_uses_atomic_write_no_temp_left_behind() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
record_install_failure_at(dir, "0.2.90");
assert!(read_install_failures_at(dir).is_some());
let leftover_tmp = dir.join(format!(".{INSTALL_FAILURES_FILE}.tmp"));
assert!(
!leftover_tmp.exists(),
"atomic_write must not leave a temp file behind"
);
}
#[test]
fn install_failure_loop_is_bounded_by_the_gate() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut emitted_exit_42 = 0u32;
for _cycle in 0..1000 {
if is_version_install_gated_at(dir, "0.2.90") {
break; }
emitted_exit_42 += 1;
record_install_failure_at(dir, "0.2.90");
}
assert!(
is_version_install_gated_at(dir, "0.2.90"),
"the loop must end with the version gated"
);
assert_eq!(
emitted_exit_42, INSTALL_FAILURE_GATE_THRESHOLD,
"node must stop emitting exit 42 after exactly the threshold cycles"
);
}
#[test]
fn capture_known_good_records_real_hash() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let bin = dir.join("freenet");
write_dummy_binary(&bin, b"hello world");
let meta = capture_known_good_at(dir, &bin).unwrap();
assert_eq!(meta.size, 11);
assert_eq!(
meta.sha256,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
let (size, hash) = sha256_file(&known_good_path(dir)).unwrap();
assert_eq!(size, meta.size);
assert_eq!(hash, meta.sha256);
}
}