use anyhow::Result;
use semver::Version;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
pub use freenet::transport::{
clear_version_mismatch, get_open_connection_count, has_version_mismatch,
version_mismatch_generation,
};
pub const EXIT_CODE_UPDATE_NEEDED: i32 = 42;
pub const SUPERVISED_ENV_VAR: &str = "FREENET_SUPERVISED";
pub const SYSTEMD_FAST_CRASH_ENV_VAR: &str = "FREENET_SYSTEMD_FAST_CRASH";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SupervisorStatus {
Supervised,
SupervisedUnverified,
Unsupervised,
}
pub fn detect_supervisor_status<F>(env_get: F) -> SupervisorStatus
where
F: Fn(&str) -> Option<String>,
{
let present = |key: &str| env_get(key).is_some_and(|v| !v.trim().is_empty());
if present(SUPERVISED_ENV_VAR) {
SupervisorStatus::Supervised
} else if present("INVOCATION_ID") {
SupervisorStatus::SupervisedUnverified
} else {
SupervisorStatus::Unsupervised
}
}
pub fn supervisor_status() -> SupervisorStatus {
detect_supervisor_status(|key| std::env::var(key).ok())
}
const INITIAL_BACKOFF: Duration = Duration::from_secs(60);
const MAX_BACKOFF: Duration = Duration::from_secs(3600);
const MAX_UPDATE_FAILURES: u32 = 3;
const GITHUB_API_URL: &str = "https://api.github.com/repos/freenet/freenet-core/releases/latest";
#[derive(Debug)]
pub struct UpdateNeededError {
pub new_version: String,
}
impl std::fmt::Display for UpdateNeededError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Update available: version {} is available on GitHub. Exiting for auto-update.",
self.new_version
)
}
}
impl std::error::Error for UpdateNeededError {}
#[derive(Debug)]
struct RateLimitedError;
impl std::fmt::Display for RateLimitedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "GitHub update poll rate-limited (token bucket empty)")
}
}
impl std::error::Error for RateLimitedError {}
#[derive(Debug, PartialEq)]
pub enum UpdateCheckResult {
Skipped,
RateLimited,
UpdateAvailable(String),
PinnedKnownBad,
}
static LOCKOUT_WARNED: AtomicBool = AtomicBool::new(false);
pub async fn check_if_update_available(current_version: &str) -> UpdateCheckResult {
if !should_attempt_update() {
let failures = get_update_failure_count();
if !LOCKOUT_WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
failures,
max = MAX_UPDATE_FAILURES,
"Auto-update is LOCKED OUT after {MAX_UPDATE_FAILURES} consecutive failed update \
attempts and will NOT retry. This peer will stay on the current version until an \
operator intervenes. Run `freenet update` manually to update and reset the \
lockout; if updates keep failing, the binary path is likely not writable by this \
account.",
);
} else {
tracing::debug!(
failures,
max = MAX_UPDATE_FAILURES,
"Skipping update check - auto-update locked out (already warned this process)"
);
}
return UpdateCheckResult::Skipped;
}
let current_backoff = get_current_backoff();
if !should_check_for_update(current_backoff) {
tracing::debug!(
backoff_secs = current_backoff.as_secs(),
"Skipping update check - backoff not elapsed"
);
return UpdateCheckResult::Skipped;
}
match get_latest_version().await {
Ok(latest) => {
record_check_time();
let current = match Version::parse(current_version) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"Failed to parse current version '{}': {}",
current_version,
e
);
increase_backoff();
return UpdateCheckResult::Skipped;
}
};
let latest_ver = match Version::parse(&latest) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to parse latest version '{}': {}", latest, e);
increase_backoff();
return UpdateCheckResult::Skipped;
}
};
if latest_ver > current {
if super::rollback::is_version_pinned_bad(&latest)
|| super::rollback::is_version_install_gated(&latest)
{
tracing::warn!(
version = %latest,
"Newer version is locally blocked (crash-loop known-bad pin or repeated \
install failures); not triggering auto-update to it (#4073)"
);
increase_backoff();
return UpdateCheckResult::PinnedKnownBad;
}
tracing::info!(
current = %current_version,
latest = %latest,
"Newer version confirmed on GitHub"
);
reset_backoff();
UpdateCheckResult::UpdateAvailable(latest)
} else {
tracing::debug!(
current = %current_version,
latest = %latest,
backoff_secs = current_backoff.as_secs(),
"No newer version on GitHub yet, will retry with increased backoff"
);
increase_backoff();
UpdateCheckResult::Skipped
}
}
Err(e) if e.downcast_ref::<RateLimitedError>().is_some() => {
tracing::debug!(
"Update check skipped: GitHub poll rate-limited; will retry when the token bucket refills"
);
UpdateCheckResult::RateLimited
}
Err(e) => {
record_check_time();
tracing::warn!(
"Failed to check GitHub for updates: {}. Will retry with increased backoff.",
e
);
increase_backoff();
UpdateCheckResult::Skipped
}
}
}
async fn get_latest_version() -> Result<String> {
if !try_consume_node_poll() {
return Err(RateLimitedError.into());
}
let client = reqwest::Client::builder()
.user_agent("freenet-updater")
.timeout(Duration::from_secs(10))
.build()?;
let response = client.get(GITHUB_API_URL).send().await?;
if !response.status().is_success() {
anyhow::bail!("GitHub API returned {}", response.status());
}
#[derive(serde::Deserialize)]
struct Release {
tag_name: String,
}
let release: Release = response.json().await?;
Ok(release.tag_name.trim_start_matches('v').to_string())
}
pub(crate) fn state_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".local/state/freenet"))
}
fn get_last_check_time() -> Option<SystemTime> {
let marker = state_dir()?.join("last_update_check");
fs::metadata(&marker).ok()?.modified().ok()
}
fn record_check_time() {
if let Some(dir) = state_dir() {
let _mkdir = fs::create_dir_all(&dir);
let marker = dir.join("last_update_check");
let _write = fs::write(&marker, "");
}
}
fn get_current_backoff() -> Duration {
let path = state_dir().map(|d| d.join("update_backoff_secs"));
path.and_then(|p| fs::read_to_string(p).ok())
.and_then(|s| s.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(INITIAL_BACKOFF)
}
fn increase_backoff() {
if let Some(dir) = state_dir() {
let _mkdir = fs::create_dir_all(&dir);
let current = get_current_backoff();
let new_backoff = std::cmp::min(current * 2, MAX_BACKOFF);
let _write = fs::write(
dir.join("update_backoff_secs"),
new_backoff.as_secs().to_string(),
);
}
}
pub fn reset_backoff() {
if let Some(dir) = state_dir() {
let _rm = fs::remove_file(dir.join("update_backoff_secs"));
}
}
fn should_check_for_update(backoff: Duration) -> bool {
get_last_check_time()
.and_then(|last| last.elapsed().ok())
.is_none_or(|elapsed| elapsed > backoff)
}
fn get_update_failure_count() -> u32 {
state_dir()
.map(|d| get_update_failure_count_at(&d))
.unwrap_or(0)
}
pub(crate) fn get_update_failure_count_at(dir: &std::path::Path) -> u32 {
match fs::read_to_string(dir.join("update_failures")) {
Ok(s) => s.trim().parse().unwrap_or(MAX_UPDATE_FAILURES),
Err(_) => 0,
}
}
pub fn record_update_failure() {
if let Some(dir) = state_dir() {
record_update_failure_at(&dir);
}
}
pub(crate) fn record_update_failure_at(dir: &std::path::Path) {
let _mkdir = fs::create_dir_all(dir);
let count = get_update_failure_count_at(dir) + 1;
let _write = fs::write(dir.join("update_failures"), count.to_string());
}
pub fn clear_update_failures() {
if let Some(dir) = state_dir() {
clear_update_failures_at(&dir);
}
}
pub(crate) fn clear_update_failures_at(dir: &std::path::Path) {
let _rm = fs::remove_file(dir.join("update_failures"));
}
pub(crate) const GITHUB_POLL_BUCKET_CAPACITY: f64 = 8.0;
pub(crate) const GITHUB_POLL_REFILL_SECS: f64 = 600.0;
const NODE_POLL_BUCKET_FILE: &str = "github_poll_bucket_node";
const INSTALL_POLL_BUCKET_FILE: &str = "github_poll_bucket_install";
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct GithubPollBucket {
pub tokens: f64,
pub updated_unix: u64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum BucketRead {
Missing,
Corrupt,
Present(GithubPollBucket),
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn github_poll_bucket_step(
prev: Option<GithubPollBucket>,
now_unix: u64,
capacity: f64,
refill_secs: f64,
) -> (GithubPollBucket, bool) {
let stored_unix = match prev {
Some(s) => now_unix.max(s.updated_unix),
None => now_unix,
};
let mut tokens = match prev {
Some(s) => {
let elapsed = now_unix.saturating_sub(s.updated_unix) as f64;
(s.tokens + elapsed / refill_secs).min(capacity)
}
None => capacity,
};
let allowed = tokens >= 1.0;
if allowed {
tokens -= 1.0;
}
(
GithubPollBucket {
tokens,
updated_unix: stored_unix,
},
allowed,
)
}
fn read_github_poll_bucket_at(dir: &std::path::Path, file: &str) -> BucketRead {
match fs::read_to_string(dir.join(file)) {
Ok(raw) => {
let mut it = raw.split_whitespace();
match (
it.next().and_then(|t| t.parse::<f64>().ok()),
it.next().and_then(|t| t.parse::<u64>().ok()),
) {
(Some(tokens), Some(updated_unix)) if tokens.is_finite() && tokens >= 0.0 => {
BucketRead::Present(GithubPollBucket {
tokens,
updated_unix,
})
}
_ => BucketRead::Corrupt,
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => BucketRead::Missing,
Err(_) => BucketRead::Corrupt,
}
}
fn write_github_poll_bucket_at(
dir: &std::path::Path,
file: &str,
bucket: &GithubPollBucket,
) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
fs::write(
dir.join(file),
format!("{} {}", bucket.tokens, bucket.updated_unix),
)
}
pub(crate) fn try_consume_github_poll_at(dir: &std::path::Path, file: &str, now_unix: u64) -> bool {
let prev = match read_github_poll_bucket_at(dir, file) {
BucketRead::Present(s) => Some(s),
BucketRead::Missing => None,
BucketRead::Corrupt => {
if write_github_poll_bucket_at(
dir,
file,
&GithubPollBucket {
tokens: 0.0,
updated_unix: now_unix,
},
)
.is_err()
{
tracing::debug!("Could not reset corrupt GitHub-poll bucket; staying denied");
}
return false;
}
};
let (next, allowed) = github_poll_bucket_step(
prev,
now_unix,
GITHUB_POLL_BUCKET_CAPACITY,
GITHUB_POLL_REFILL_SECS,
);
let persisted = write_github_poll_bucket_at(dir, file, &next).is_ok();
allowed && persisted
}
fn try_consume_poll_bucket(file: &str) -> bool {
match state_dir() {
Some(dir) => try_consume_github_poll_at(&dir, file, now_unix()),
None => false,
}
}
pub(crate) fn try_consume_node_poll() -> bool {
try_consume_poll_bucket(NODE_POLL_BUCKET_FILE)
}
pub(crate) fn try_consume_install_poll() -> bool {
try_consume_poll_bucket(INSTALL_POLL_BUCKET_FILE)
}
pub fn should_attempt_update() -> bool {
state_dir()
.map(|d| should_attempt_update_at(&d))
.unwrap_or(false)
}
pub(crate) fn should_attempt_update_at(dir: &std::path::Path) -> bool {
get_update_failure_count_at(dir) < MAX_UPDATE_FAILURES
}
pub fn has_reached_max_backoff() -> bool {
get_current_backoff() >= MAX_BACKOFF
}
pub async fn startup_update_check(current_version: &str) -> Option<String> {
startup_update_check_with_fetcher(current_version, get_latest_version).await
}
pub(crate) async fn startup_update_check_with_fetcher<F, Fut>(
current_version: &str,
fetcher: F,
) -> Option<String>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<String>>,
{
let latest = match fetcher().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"Startup update check: failed to fetch latest version: {}. \
Continuing with current binary.",
e
);
return None;
}
};
compare_versions_for_startup(current_version, &latest)
}
pub(crate) fn compare_versions_for_startup(current: &str, latest: &str) -> Option<String> {
let current_ver = match Version::parse(current) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"Startup update check: failed to parse current version '{}': {}",
current,
e
);
return None;
}
};
let latest_ver = match Version::parse(latest) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"Startup update check: failed to parse latest version '{}': {}",
latest,
e
);
return None;
}
};
if latest_ver > current_ver {
Some(latest.to_string())
} else {
None
}
}
pub const UPDATE_REPOLL_INTERVAL: Duration = Duration::from_secs(6 * 3600);
pub const UPDATE_REPOLL_JITTER_FRACTION: f64 = 0.25;
pub fn jittered_repoll_interval(base: Duration, jitter_fraction: f64, rand_unit: f64) -> Duration {
let jitter_fraction = jitter_fraction.clamp(0.0, 1.0);
let rand_unit = rand_unit.clamp(0.0, 1.0);
let factor = 1.0 - jitter_fraction + 2.0 * jitter_fraction * rand_unit;
base.mul_f64(factor)
}
#[cfg(test)]
mod tests {
use super::*;
use freenet::transport::{
set_open_connection_count, signal_version_mismatch, version_mismatch_generation,
};
#[test]
fn test_version_mismatch_flag() {
clear_version_mismatch();
assert!(!has_version_mismatch());
signal_version_mismatch();
assert!(has_version_mismatch());
clear_version_mismatch();
assert!(!has_version_mismatch());
}
#[test]
fn test_mismatch_generation_increments() {
let gen_before = version_mismatch_generation();
signal_version_mismatch();
let gen_after = version_mismatch_generation();
assert!(
gen_after > gen_before,
"generation should increment on each signal"
);
signal_version_mismatch();
assert!(version_mismatch_generation() > gen_after);
}
#[test]
fn test_open_connection_count() {
set_open_connection_count(0);
assert_eq!(get_open_connection_count(), 0);
set_open_connection_count(5);
assert_eq!(get_open_connection_count(), 5);
set_open_connection_count(0);
assert_eq!(get_open_connection_count(), 0);
}
#[test]
fn test_update_needed_error_display() {
let err = UpdateNeededError {
new_version: "0.1.74".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("0.1.74"));
assert!(msg.contains("auto-update"));
}
#[test]
fn test_compare_versions_newer_available() {
assert_eq!(
compare_versions_for_startup("0.1.74", "0.1.75"),
Some("0.1.75".to_string())
);
assert_eq!(
compare_versions_for_startup("0.1.74", "0.2.0"),
Some("0.2.0".to_string())
);
assert_eq!(
compare_versions_for_startup("0.1.74", "1.0.0"),
Some("1.0.0".to_string())
);
}
#[test]
fn test_compare_versions_already_current() {
assert_eq!(compare_versions_for_startup("0.1.75", "0.1.75"), None);
}
#[test]
fn test_compare_versions_never_downgrades() {
assert_eq!(compare_versions_for_startup("0.2.0", "0.1.99"), None);
assert_eq!(compare_versions_for_startup("1.0.0", "0.9.99"), None);
}
#[test]
fn test_compare_versions_unparseable_fails_open() {
assert_eq!(
compare_versions_for_startup("not-a-version", "0.1.75"),
None
);
assert_eq!(compare_versions_for_startup("0.1.74", "also-garbage"), None);
assert_eq!(compare_versions_for_startup("", "0.1.75"), None);
}
#[test]
fn test_compare_versions_prerelease_semver_semantics() {
assert_eq!(
compare_versions_for_startup("0.1.75-alpha", "0.1.75"),
Some("0.1.75".to_string())
);
assert_eq!(compare_versions_for_startup("0.1.75", "0.1.75-alpha"), None);
}
#[tokio::test]
async fn test_startup_check_fetcher_error_returns_none() {
let result = startup_update_check_with_fetcher("0.1.74", || async {
anyhow::bail!("simulated network failure")
})
.await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_startup_check_finds_newer_version() {
let result =
startup_update_check_with_fetcher("0.1.74", || async { Ok("0.1.75".to_string()) })
.await;
assert_eq!(result, Some("0.1.75".to_string()));
}
#[tokio::test]
async fn test_startup_check_no_update_when_current() {
let result =
startup_update_check_with_fetcher("0.1.75", || async { Ok("0.1.75".to_string()) })
.await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_startup_check_refuses_downgrade() {
let result =
startup_update_check_with_fetcher("0.2.0", || async { Ok("0.1.99".to_string()) }).await;
assert_eq!(result, None);
}
#[test]
fn test_update_failure_counter_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert_eq!(get_update_failure_count_at(dir), 0);
record_update_failure_at(dir);
assert_eq!(get_update_failure_count_at(dir), 1);
record_update_failure_at(dir);
record_update_failure_at(dir);
assert_eq!(get_update_failure_count_at(dir), 3);
clear_update_failures_at(dir);
assert_eq!(get_update_failure_count_at(dir), 0);
clear_update_failures_at(dir);
assert_eq!(get_update_failure_count_at(dir), 0);
}
#[test]
fn test_should_attempt_update_locks_out_after_max_failures() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert!(should_attempt_update_at(dir), "fresh state: no lockout");
for _ in 0..MAX_UPDATE_FAILURES - 1 {
record_update_failure_at(dir);
assert!(
should_attempt_update_at(dir),
"below threshold still allowed"
);
}
record_update_failure_at(dir);
assert!(
!should_attempt_update_at(dir),
"MAX_UPDATE_FAILURES reached: auto-update must be disabled"
);
clear_update_failures_at(dir);
assert!(
should_attempt_update_at(dir),
"after clear: updates re-enabled (manual install recovery)"
);
}
#[test]
fn test_update_failure_counter_persists_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
record_update_failure_at(dir);
record_update_failure_at(dir);
let on_disk = std::fs::read_to_string(dir.join("update_failures"))
.expect("failure counter file should exist after recording");
assert_eq!(on_disk.trim(), "2");
}
#[test]
fn test_corrupt_counter_file_is_treated_as_max() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("update_failures"), "garbage").unwrap();
assert_eq!(get_update_failure_count_at(dir), MAX_UPDATE_FAILURES);
assert!(!should_attempt_update_at(dir));
std::fs::write(dir.join("update_failures"), "").unwrap();
assert_eq!(get_update_failure_count_at(dir), MAX_UPDATE_FAILURES);
assert!(!should_attempt_update_at(dir));
std::fs::write(dir.join("update_failures"), "-1").unwrap();
assert_eq!(get_update_failure_count_at(dir), MAX_UPDATE_FAILURES);
clear_update_failures_at(dir);
assert_eq!(get_update_failure_count_at(dir), 0);
assert!(should_attempt_update_at(dir));
}
#[test]
fn test_check_if_update_available_does_not_clear_failure_counter() {
let src = include_str!("auto_update.rs");
let (_, after_fn_start) = src
.split_once("pub async fn check_if_update_available(")
.expect("check_if_update_available definition not found");
let (body, _) = after_fn_start
.split_once("\n}\n")
.expect("could not locate end of check_if_update_available");
let code_only: String = body
.lines()
.map(|line| line.split_once("//").map(|(c, _)| c).unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
assert!(
!code_only.contains("clear_update_failures("),
"check_if_update_available must not call clear_update_failures() — \
doing so wipes the #3934 lockout counter on every peer-mismatch \
GitHub check. Only a successful install (update.rs) or a verified \
AlreadyUpToDate exit should clear failures."
);
}
#[test]
fn test_should_attempt_update_conservative_when_state_dir_missing() {
let src = include_str!("auto_update.rs");
let (_, after_fn_start) = src
.split_once("pub fn should_attempt_update() -> bool {")
.expect("should_attempt_update definition not found");
let (body, _) = after_fn_start
.split_once('}')
.expect("could not locate end of should_attempt_update");
assert!(
body.contains("unwrap_or(false)"),
"should_attempt_update must fall back to `false` when state_dir \
is unavailable — falling back to `true` allows the exit-42 loop \
to run unbounded on Windows service accounts without USERPROFILE."
);
}
#[test]
fn test_detect_supervisor_status_marker_present() {
let env = |key: &str| {
if key == SUPERVISED_ENV_VAR {
Some("1".to_string())
} else {
None
}
};
assert_eq!(detect_supervisor_status(env), SupervisorStatus::Supervised);
}
#[test]
fn test_detect_supervisor_status_invocation_id_is_unverified() {
let env = |key: &str| {
if key == "INVOCATION_ID" {
Some("a1b2c3d4".to_string())
} else {
None
}
};
assert_eq!(
detect_supervisor_status(env),
SupervisorStatus::SupervisedUnverified
);
}
#[test]
fn test_detect_supervisor_status_marker_beats_invocation_id() {
let env = |key: &str| match key {
SUPERVISED_ENV_VAR => Some("1".to_string()),
"INVOCATION_ID" => Some("a1b2c3d4".to_string()),
_ => None,
};
assert_eq!(detect_supervisor_status(env), SupervisorStatus::Supervised);
}
#[test]
fn test_detect_supervisor_status_unsupervised_when_absent() {
let env = |_key: &str| None;
assert_eq!(
detect_supervisor_status(env),
SupervisorStatus::Unsupervised
);
}
#[test]
fn test_detect_supervisor_status_empty_and_whitespace_are_not_evidence() {
for value in ["", " ", "\t", "\n"] {
let env = move |key: &str| {
if key == SUPERVISED_ENV_VAR || key == "INVOCATION_ID" {
Some(value.to_string())
} else {
None
}
};
assert_eq!(
detect_supervisor_status(env),
SupervisorStatus::Unsupervised,
"value {value:?} must not count as supervisor evidence"
);
}
}
#[test]
fn test_locked_out_update_check_is_loud() {
let src = include_str!("auto_update.rs");
let (_, after_fn_start) = src
.split_once("pub async fn check_if_update_available(")
.expect("check_if_update_available definition not found");
let (_, after_guard) = after_fn_start
.split_once("if !should_attempt_update() {")
.expect("lockout guard not found");
let (guard_body, _) = after_guard
.split_once("return UpdateCheckResult::Skipped;")
.expect("could not locate end of lockout guard");
let code_only: String = guard_body
.lines()
.map(|line| line.split_once("//").map(|(c, _)| c).unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
assert!(
code_only.contains("tracing::warn!"),
"the auto-update lockout skip must log at warn! level so a permanent \
lockout is diagnosable (#4580), not silently dropped at debug!"
);
}
#[test]
fn test_github_poll_bucket_allows_initial_burst_then_caps() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let now = 1_000_000u64;
let cap = GITHUB_POLL_BUCKET_CAPACITY as u64;
for i in 0..cap {
assert!(
try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now),
"poll {i} within capacity must be allowed"
);
}
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now),
"poll beyond capacity at the same instant must be denied"
);
}
#[test]
fn test_github_poll_bucket_refills_over_time() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut now = 2_000_000u64;
let cap = GITHUB_POLL_BUCKET_CAPACITY as u64;
for _ in 0..cap {
assert!(try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now));
}
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now),
"drained"
);
now += (GITHUB_POLL_REFILL_SECS as u64) / 2;
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now),
"half a refill interval is < 1 token"
);
now += GITHUB_POLL_REFILL_SECS as u64;
assert!(
try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now),
"one refill interval should grant exactly one token"
);
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now),
"only one token should have refilled"
);
}
#[test]
fn test_github_poll_bucket_refill_is_capped_at_capacity() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let now = 3_000_000u64;
write_github_poll_bucket_at(
dir,
NODE_POLL_BUCKET_FILE,
&GithubPollBucket {
tokens: 0.0,
updated_unix: now,
},
)
.unwrap();
let far_future = now + (GITHUB_POLL_REFILL_SECS as u64) * 10_000;
let cap = GITHUB_POLL_BUCKET_CAPACITY as u64;
for _ in 0..cap {
assert!(try_consume_github_poll_at(
dir,
NODE_POLL_BUCKET_FILE,
far_future
));
}
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, far_future),
"refill must be capped at CAPACITY regardless of idle time"
);
}
#[test]
fn test_github_poll_bucket_denies_on_corrupt_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join(NODE_POLL_BUCKET_FILE), "not-a-bucket").unwrap();
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, 4_000_000),
"corrupt bucket must deny the poll"
);
assert!(matches!(
read_github_poll_bucket_at(dir, NODE_POLL_BUCKET_FILE),
BucketRead::Present(_)
));
assert!(try_consume_github_poll_at(
dir,
NODE_POLL_BUCKET_FILE,
4_000_000 + GITHUB_POLL_REFILL_SECS as u64
));
}
#[test]
fn test_github_poll_bucket_nan_and_negative_are_corrupt() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for bad in ["NaN 100", "inf 100", "-1 100", "5", "5 notanint"] {
std::fs::write(dir.join(NODE_POLL_BUCKET_FILE), bad).unwrap();
assert_eq!(
read_github_poll_bucket_at(dir, NODE_POLL_BUCKET_FILE),
BucketRead::Corrupt,
"{bad:?} must read as corrupt"
);
}
}
#[test]
fn test_github_poll_bucket_backwards_clock_gives_no_credit() {
let prev = GithubPollBucket {
tokens: 0.0,
updated_unix: 5_000_000,
};
let (next, allowed) = github_poll_bucket_step(
Some(prev),
4_000_000, GITHUB_POLL_BUCKET_CAPACITY,
GITHUB_POLL_REFILL_SECS,
);
assert!(
!allowed,
"no token should be available after a backwards clock"
);
assert_eq!(next.tokens, 0.0, "no negative-time credit");
assert_eq!(
next.updated_unix, 5_000_000,
"stored timestamp must not move backwards"
);
}
#[test]
fn test_github_poll_bucket_dip_then_recover_grants_no_credit() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let t = 10_000_000u64;
let cap = GITHUB_POLL_BUCKET_CAPACITY as u64;
for _ in 0..cap {
assert!(try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, t));
}
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, t),
"drained at T"
);
assert!(
!try_consume_github_poll_at(
dir,
NODE_POLL_BUCKET_FILE,
t - GITHUB_POLL_REFILL_SECS as u64
),
"still empty during the backwards dip"
);
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, t),
"returning to T must not grant credit for pre-dip time"
);
assert!(try_consume_github_poll_at(
dir,
NODE_POLL_BUCKET_FILE,
t + GITHUB_POLL_REFILL_SECS as u64
));
}
#[test]
fn test_github_poll_bucket_missing_is_full_not_denied() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert_eq!(
read_github_poll_bucket_at(dir, NODE_POLL_BUCKET_FILE),
BucketRead::Missing
);
assert!(
try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, 6_000_000),
"first poll on a fresh node must be allowed"
);
}
#[cfg(unix)]
#[test]
fn test_github_poll_bucket_fails_closed_when_unpersistable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let orig = std::fs::metadata(dir).unwrap().permissions();
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let allowed = try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, 8_000_000);
std::fs::set_permissions(dir, orig).unwrap();
assert!(
!allowed,
"an unpersistable consume must deny (fail closed), not allow"
);
}
#[test]
fn test_rate_limited_poll_does_not_record_check_time_or_grow_backoff() {
let src = include_str!("auto_update.rs");
let (_, body) = src
.split_once("pub async fn check_if_update_available(")
.expect("check_if_update_available not found");
let gate = body
.find("if !should_check_for_update(current_backoff) {")
.expect("backoff gate not found");
let match_pos = body
.find("match get_latest_version().await {")
.expect("get_latest_version match not found");
let between = &body[gate..match_pos];
let between_code: String = between
.lines()
.map(|l| l.split_once("//").map(|(c, _)| c).unwrap_or(l))
.collect::<Vec<_>>()
.join("\n");
assert!(
!between_code.contains("record_check_time()"),
"record_check_time() must NOT run unconditionally before the poll — a \
rate-limited poll would then advance the backoff gate and mask RateLimited"
);
let (_, rl_arm) = body
.split_once("e.downcast_ref::<RateLimitedError>().is_some() =>")
.expect("RateLimited arm not found");
let (rl_body, _) = rl_arm
.split_once("UpdateCheckResult::RateLimited")
.expect("RateLimited arm body not found");
let rl_code: String = rl_body
.lines()
.map(|l| l.split_once("//").map(|(c, _)| c).unwrap_or(l))
.collect::<Vec<_>>()
.join("\n");
assert!(
!rl_code.contains("record_check_time(") && !rl_code.contains("increase_backoff("),
"the RateLimited arm must not record a check time or grow backoff"
);
}
#[test]
fn test_get_latest_version_consults_rate_limit_bucket() {
let src = include_str!("auto_update.rs");
let (_, body) = src
.split_once("async fn get_latest_version() -> Result<String> {")
.expect("get_latest_version definition not found");
let (head, _) = body
.split_once("reqwest::Client::builder()")
.expect("client builder not found");
assert!(
head.contains("try_consume_node_poll()"),
"get_latest_version must consume a rate-limit token before hitting GitHub"
);
}
#[test]
fn test_node_and_install_buckets_are_independent() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let now = 7_500_000u64;
let cap = GITHUB_POLL_BUCKET_CAPACITY as u64;
for _ in 0..cap {
assert!(try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now));
}
assert!(
!try_consume_github_poll_at(dir, NODE_POLL_BUCKET_FILE, now),
"node bucket drained"
);
for _ in 0..cap {
assert!(
try_consume_github_poll_at(dir, INSTALL_POLL_BUCKET_FILE, now),
"install bucket must be unaffected by node-bucket drain"
);
}
}
#[test]
fn test_get_latest_release_consults_install_bucket() {
let src = include_str!("update.rs");
let (_, body) = src
.split_once("async fn get_latest_release(")
.expect("get_latest_release definition not found");
let (head, _) = body
.split_once("reqwest::Client::builder()")
.expect("client builder not found");
assert!(
head.contains("try_consume_install_poll()"),
"get_latest_release must consume an INSTALL-bucket token before hitting GitHub"
);
}
#[test]
fn test_backoff_constants() {
assert_eq!(INITIAL_BACKOFF, Duration::from_secs(60));
assert_eq!(MAX_BACKOFF, Duration::from_secs(3600));
let mut backoff = INITIAL_BACKOFF;
for _ in 0..6 {
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
}
assert_eq!(backoff, MAX_BACKOFF);
}
#[test]
fn test_jittered_repoll_interval_bounds() {
let base = UPDATE_REPOLL_INTERVAL;
let frac = UPDATE_REPOLL_JITTER_FRACTION;
let min = base.mul_f64(1.0 - frac);
let max = base.mul_f64(1.0 + frac);
for i in 0..=100 {
let rand_unit = i as f64 / 100.0;
let got = jittered_repoll_interval(base, frac, rand_unit);
assert!(
got >= min && got <= max,
"rand_unit={rand_unit}: {got:?} not in [{min:?}, {max:?}]"
);
}
}
#[test]
fn test_jittered_repoll_interval_endpoints_and_midpoint() {
let base = Duration::from_secs(1000);
assert_eq!(
jittered_repoll_interval(base, 0.25, 0.0),
Duration::from_secs(750)
);
assert_eq!(
jittered_repoll_interval(base, 0.25, 0.5),
Duration::from_secs(1000)
);
assert_eq!(
jittered_repoll_interval(base, 0.25, 1.0),
Duration::from_secs(1250)
);
}
#[test]
fn test_jittered_repoll_interval_deterministic() {
let a = jittered_repoll_interval(UPDATE_REPOLL_INTERVAL, 0.25, 0.37);
let b = jittered_repoll_interval(UPDATE_REPOLL_INTERVAL, 0.25, 0.37);
assert_eq!(a, b);
}
#[test]
fn test_jittered_repoll_interval_clamps_out_of_range_inputs() {
let base = Duration::from_secs(1000);
assert_eq!(
jittered_repoll_interval(base, 0.25, -5.0),
jittered_repoll_interval(base, 0.25, 0.0)
);
assert_eq!(
jittered_repoll_interval(base, 0.25, 5.0),
jittered_repoll_interval(base, 0.25, 1.0)
);
assert_eq!(
jittered_repoll_interval(base, 9.0, 0.0),
Duration::from_secs(0)
);
assert_eq!(
jittered_repoll_interval(base, 9.0, 1.0),
Duration::from_secs(2000)
);
}
#[test]
fn test_repoll_interval_is_under_github_rate_limit() {
let min =
jittered_repoll_interval(UPDATE_REPOLL_INTERVAL, UPDATE_REPOLL_JITTER_FRACTION, 0.0);
assert!(
min >= Duration::from_secs(3600),
"minimum re-poll interval {min:?} must be >= 1h to stay well under \
GitHub's 60 req/hr unauthenticated limit"
);
}
}