use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::OnceLock;
use tracing::level_filters::LevelFilter;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{Layer, Registry};
const LOG_DIR_MAX_BYTES: u64 = 512 * 1024 * 1024;
const LOG_RETENTION_HOURS: u64 = 72;
const LOG_PRUNE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LogFamily {
Main,
Error,
}
fn rotating_log_family(name: &str) -> Option<LogFamily> {
let (family, stem) = if let Some(rest) = name.strip_prefix("freenet.error.") {
(LogFamily::Error, rest)
} else if let Some(rest) = name.strip_prefix("freenet.") {
(LogFamily::Main, rest)
} else {
return None;
};
let date_part = stem.strip_suffix(".log")?;
if !date_part.is_empty()
&& date_part.contains('-')
&& date_part.chars().all(|c| c.is_ascii_digit() || c == '-')
{
Some(family)
} else {
None
}
}
static LOG_GUARDS: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
pub fn get_log_dir() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
{
dirs::home_dir().map(|h| h.join(".local/state/freenet"))
}
#[cfg(target_os = "macos")]
{
dirs::home_dir().map(|h| h.join("Library/Logs/freenet"))
}
#[cfg(target_os = "windows")]
{
dirs::data_local_dir().map(|d| d.join("freenet").join("logs"))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
None
}
}
#[derive(Debug, Clone)]
struct LogFile {
path: std::path::PathBuf,
modified: std::time::SystemTime,
size: u64,
family: LogFamily,
}
fn cleanup_old_logs(log_dir: &std::path::Path) {
use std::time::{Duration, SystemTime};
let retention = Duration::from_secs(LOG_RETENTION_HOURS * 3600);
let cutoff = SystemTime::now() - retention;
let Ok(entries) = std::fs::read_dir(log_dir) else {
return;
};
let mut files: Vec<LogFile> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(family) = rotating_log_family(name) else {
continue;
};
let Ok(metadata) = path.metadata() else {
continue;
};
let Ok(modified) = metadata.modified() else {
continue;
};
files.push(LogFile {
path,
modified,
size: metadata.len(),
family,
});
}
prune_log_files(files, cutoff, LOG_DIR_MAX_BYTES);
}
fn live_file_indices(files: &[LogFile]) -> Vec<usize> {
let mut newest: Vec<(LogFamily, usize)> = Vec::new();
for (idx, file) in files.iter().enumerate() {
match newest.iter_mut().find(|(family, _)| *family == file.family) {
Some((_, slot)) => *slot = idx,
None => newest.push((file.family, idx)),
}
}
newest.into_iter().map(|(_, idx)| idx).collect()
}
fn prune_log_files(mut files: Vec<LogFile>, cutoff: std::time::SystemTime, max_bytes: u64) {
files.sort_by(|a, b| {
a.modified
.cmp(&b.modified)
.then_with(|| a.path.cmp(&b.path))
});
let live = live_file_indices(&files);
let mut retained: Vec<usize> = Vec::with_capacity(files.len());
let mut retained_bytes: u64 = 0;
for (idx, file) in files.iter().enumerate() {
if !live.contains(&idx) && file.modified < cutoff {
if let Err(e) = std::fs::remove_file(&file.path) {
eprintln!(
"Failed to remove old log file {}: {}",
file.path.display(),
e
);
}
continue;
}
retained.push(idx);
retained_bytes = retained_bytes.saturating_add(file.size);
}
for idx in retained {
if retained_bytes <= max_bytes {
break;
}
if live.contains(&idx) {
continue;
}
let file = &files[idx];
match std::fs::remove_file(&file.path) {
Ok(()) => retained_bytes = retained_bytes.saturating_sub(file.size),
Err(e) => eprintln!(
"Failed to enforce log dir size cap on {}: {}",
file.path.display(),
e
),
}
}
}
async fn periodic_log_prune(log_dir: PathBuf) {
let jitter_secs = crate::config::GlobalRng::random_range(45u64..=75u64);
tokio::time::sleep(std::time::Duration::from_secs(jitter_secs)).await;
let mut interval = tokio::time::interval(LOG_PRUNE_INTERVAL);
interval.tick().await;
loop {
interval.tick().await;
cleanup_old_logs(&log_dir);
}
}
fn error_log_directives() -> String {
std::env::var(tracing_subscriber::EnvFilter::DEFAULT_ENV).unwrap_or_default()
}
fn build_error_filter<S>(directives: &str) -> impl tracing_subscriber::layer::Filter<S> + 'static
where
S: 'static,
{
use tracing_subscriber::filter::FilterExt;
tracing_subscriber::EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.parse_lossy(directives)
.and(LevelFilter::WARN)
}
pub fn init_tracer(
level: Option<LevelFilter>,
_endpoint: Option<String>,
log_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
#[cfg(feature = "console-subscriber")]
{
if std::env::var("TOKIO_CONSOLE").is_ok() {
console_subscriber::init();
tracing::info!(
"Tokio console subscriber initialized. Connect with 'tokio-console' command."
);
return Ok(());
}
}
let default_filter = if cfg!(any(test, debug_assertions)) {
LevelFilter::DEBUG
} else {
LevelFilter::INFO
};
let default_filter = level.unwrap_or(default_filter);
use tracing_subscriber::layer::SubscriberExt;
let disabled_logs = std::env::var("FREENET_DISABLE_LOGS").is_ok();
if disabled_logs {
return Ok(());
}
let to_stderr = std::env::var("FREENET_LOG_TO_STDERR").is_ok();
let use_json = std::env::var("FREENET_LOG_FORMAT")
.map(|v| v.eq_ignore_ascii_case("json"))
.unwrap_or(false);
let use_file_logging = !to_stderr && log_dir.is_some();
fn build_filter(default_filter: LevelFilter) -> tracing_subscriber::EnvFilter {
tracing_subscriber::EnvFilter::builder()
.with_default_directive(default_filter.into())
.from_env_lossy()
.add_directive("moka=off".parse().expect("infallible"))
.add_directive("sqlx=error".parse().expect("infallible"))
}
let filter_layer = build_filter(default_filter);
let also_log_to_console = std::io::stdout().is_terminal();
let rate_limit: u64 = std::env::var("FREENET_LOG_RATE_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(crate::util::rate_limit_layer::DEFAULT_MAX_EVENTS_PER_SECOND);
let per_callsite_limit: u64 = std::env::var("FREENET_LOG_RATE_LIMIT_PER_CALLSITE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(crate::util::rate_limit_layer::DEFAULT_MAX_EVENTS_PER_CALLSITE_PER_SECOND);
let rate_limit_enabled = !cfg!(any(test, debug_assertions))
&& std::env::var("FREENET_DISABLE_LOG_RATE_LIMIT").is_err();
let rate_limiter = if rate_limit_enabled {
Some(crate::util::rate_limit_layer::RateLimiter::new(rate_limit))
} else {
None
};
let per_callsite_limiter = if rate_limit_enabled {
Some(crate::util::rate_limit_layer::PerCallsiteRateLimiter::new(
per_callsite_limit,
))
} else {
None
};
if use_file_logging {
if let Some(log_dir) = log_dir {
if let Err(e) = std::fs::create_dir_all(log_dir) {
eprintln!("Warning: Failed to create log directory: {e}");
return init_stdout_tracer(
default_filter,
to_stderr,
use_json,
filter_layer,
rate_limiter,
per_callsite_limiter,
);
}
cleanup_old_logs(log_dir);
crate::config::GlobalExecutor::spawn(periodic_log_prune(log_dir.to_path_buf()));
let main_appender = RollingFileAppender::builder()
.rotation(Rotation::HOURLY)
.filename_prefix("freenet")
.filename_suffix("log")
.build(log_dir)
.map_err(|e| anyhow::anyhow!("Failed to create log appender: {e}"))?;
let error_appender = RollingFileAppender::builder()
.rotation(Rotation::HOURLY)
.filename_prefix("freenet.error")
.filename_suffix("log")
.build(log_dir)
.map_err(|e| anyhow::anyhow!("Failed to create error log appender: {e}"))?;
let (main_writer, main_guard) = tracing_appender::non_blocking(main_appender);
let (error_writer, error_guard) = tracing_appender::non_blocking(error_appender);
if LOG_GUARDS.set(vec![main_guard, error_guard]).is_err() {
return Err(anyhow::anyhow!(
"LOG_GUARDS already initialized; tracer cannot be re-initialized"
));
}
if let Some(rate_limiter) = rate_limiter.clone() {
let per_callsite = per_callsite_limiter.clone();
let rate_filter = tracing_subscriber::filter::DynFilterFn::new(move |meta, _cx| {
per_callsite
.as_ref()
.map(|pc| pc.should_allow(meta))
.unwrap_or(true)
&& rate_limiter.should_allow()
});
let base = Registry::default().with(rate_filter);
let main_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(main_writer.clone())
.with_filter(filter_layer);
let error_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(error_writer.clone())
.with_filter(build_error_filter(&error_log_directives()));
if also_log_to_console {
let console_filter = build_filter(default_filter);
let console_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.pretty()
.with_filter(console_filter);
let subscriber = base.with(main_layer).with(error_layer).with(console_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
} else {
let subscriber = base.with(main_layer).with(error_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
}
} else {
let main_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(main_writer)
.with_filter(filter_layer);
let error_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(error_writer)
.with_filter(build_error_filter(&error_log_directives()));
if also_log_to_console {
let console_filter = build_filter(default_filter);
let console_layer = tracing_subscriber::fmt::layer()
.with_level(true)
.pretty()
.with_filter(console_filter);
let subscriber = Registry::default()
.with(main_layer)
.with(error_layer)
.with(console_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
} else {
let subscriber = Registry::default().with(main_layer).with(error_layer);
tracing::subscriber::set_global_default(subscriber)
.expect("Error setting subscriber");
}
}
return Ok(());
}
}
init_stdout_tracer(
default_filter,
to_stderr,
use_json,
filter_layer,
rate_limiter,
per_callsite_limiter,
)
}
fn init_stdout_tracer(
_default_filter: LevelFilter,
to_stderr: bool,
use_json: bool,
filter_layer: tracing_subscriber::EnvFilter,
rate_limiter: Option<crate::util::rate_limit_layer::RateLimiter>,
per_callsite_limiter: Option<crate::util::rate_limit_layer::PerCallsiteRateLimiter>,
) -> anyhow::Result<()> {
use tracing_subscriber::layer::SubscriberExt;
fn make_layer<S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>>(
to_stderr: bool,
use_json: bool,
) -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync> {
if to_stderr {
if use_json {
tracing_subscriber::fmt::layer()
.with_level(true)
.json()
.with_file(cfg!(any(test, debug_assertions)))
.with_line_number(cfg!(any(test, debug_assertions)))
.with_writer(std::io::stderr)
.boxed()
} else {
let layer = tracing_subscriber::fmt::layer().with_level(true).pretty();
let layer = if cfg!(any(test, debug_assertions)) {
layer.with_file(true).with_line_number(true)
} else {
layer
};
layer.with_writer(std::io::stderr).boxed()
}
} else if use_json {
tracing_subscriber::fmt::layer()
.with_level(true)
.json()
.with_file(cfg!(any(test, debug_assertions)))
.with_line_number(cfg!(any(test, debug_assertions)))
.boxed()
} else {
let layer = tracing_subscriber::fmt::layer().with_level(true).pretty();
if cfg!(any(test, debug_assertions)) {
layer.with_file(true).with_line_number(true).boxed()
} else {
layer.boxed()
}
}
}
if let Some(rate_limiter) = rate_limiter {
let per_callsite = per_callsite_limiter.clone();
let rate_filter = tracing_subscriber::filter::DynFilterFn::new(move |meta, _cx| {
per_callsite
.as_ref()
.map(|pc| pc.should_allow(meta))
.unwrap_or(true)
&& rate_limiter.should_allow()
});
let base = Registry::default().with(rate_filter);
let layer = make_layer(to_stderr, use_json);
let subscriber = base.with(layer.with_filter(filter_layer));
tracing::subscriber::set_global_default(subscriber).expect("Error setting subscriber");
} else {
let layer = make_layer(to_stderr, use_json);
let subscriber = Registry::default().with(layer.with_filter(filter_layer));
tracing::subscriber::set_global_default(subscriber).expect("Error setting subscriber");
}
Ok(())
}
#[cfg(test)]
mod error_filter_tests {
use super::{build_error_filter, error_log_directives};
use std::io;
use std::sync::{Arc, Mutex};
use tracing::level_filters::LevelFilter;
use tracing_subscriber::layer::{Filter, SubscriberExt};
use tracing_subscriber::{Layer, Registry};
fn error_hint(directives: &str) -> Option<LevelFilter> {
Filter::<Registry>::max_level_hint(&build_error_filter::<Registry>(directives))
}
#[test]
fn env_filter_default_directive_is_a_fallback_not_a_floor() {
let unfloored = tracing_subscriber::EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.parse_lossy("info");
assert_eq!(
unfloored.max_level_hint(),
Some(LevelFilter::INFO),
"with_default_directive(WARN) must be discarded once RUST_LOG parses to \
any directive — that discard is the #5015 bug"
);
}
#[test]
fn error_filter_is_warn_when_rust_log_unset() {
assert_eq!(error_hint(""), Some(LevelFilter::WARN));
}
#[test]
fn error_filter_floors_verbose_rust_log_at_warn() {
for directives in ["info", "debug", "trace", "freenet=info", "freenet=trace"] {
assert_eq!(
error_hint(directives),
Some(LevelFilter::WARN),
"RUST_LOG={directives} must not admit anything below WARN into the error log"
);
}
}
#[test]
fn error_filter_floors_per_target_directives_at_warn() {
for directives in [
"freenet::ring=debug",
"info,freenet::ring=debug",
"freenet::ring=trace,freenet::transport=debug",
] {
assert_eq!(
error_hint(directives),
Some(LevelFilter::WARN),
"RUST_LOG={directives} must not admit anything below WARN into the error log"
);
}
}
#[test]
fn error_filter_honors_a_more_restrictive_rust_log() {
assert_eq!(
error_hint("error"),
Some(LevelFilter::ERROR),
"RUST_LOG=error must leave the error log at ERROR only, not widen it to WARN"
);
assert_eq!(
error_hint("off"),
Some(LevelFilter::OFF),
"RUST_LOG=off must silence the error log too"
);
assert_eq!(
error_hint("freenet=error"),
Some(LevelFilter::ERROR),
"a more restrictive per-target directive must also be honored"
);
}
#[test]
fn error_log_directives_reads_rust_log() {
assert_eq!(tracing_subscriber::EnvFilter::DEFAULT_ENV, "RUST_LOG");
assert_eq!(
error_log_directives(),
std::env::var("RUST_LOG").unwrap_or_default()
);
}
#[derive(Clone)]
struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
impl io::Write for CaptureWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter {
type Writer = CaptureWriter;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
#[test]
fn error_layer_writes_only_warn_and_above_under_verbose_rust_log() {
let sink = Arc::new(Mutex::new(Vec::new()));
let layer = tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(CaptureWriter(sink.clone()))
.with_filter(build_error_filter("debug"));
tracing::subscriber::with_default(Registry::default().with(layer), || {
tracing::debug!("i5015-debug-must-be-dropped");
tracing::info!("i5015-info-must-be-dropped");
tracing::warn!("i5015-warn-must-be-kept");
tracing::error!("i5015-error-must-be-kept");
});
let captured = String::from_utf8(sink.lock().unwrap().clone()).unwrap();
assert!(
!captured.contains("i5015-debug-must-be-dropped"),
"DEBUG leaked into the error log under RUST_LOG=debug: {captured}"
);
assert!(
!captured.contains("i5015-info-must-be-dropped"),
"INFO leaked into the error log under RUST_LOG=debug: {captured}"
);
assert!(
captured.contains("i5015-warn-must-be-kept"),
"WARN must still reach the error log: {captured}"
);
assert!(
captured.contains("i5015-error-must-be-kept"),
"ERROR must still reach the error log: {captured}"
);
}
fn assert_error_floor_does_not_starve_main_layer(with_global_rate_filter: bool) {
let main_sink = Arc::new(Mutex::new(Vec::new()));
let error_sink = Arc::new(Mutex::new(Vec::new()));
macro_rules! layers {
() => {
(
tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(CaptureWriter(main_sink.clone()))
.with_filter(tracing_subscriber::EnvFilter::builder().parse_lossy("info")),
tracing_subscriber::fmt::layer()
.with_level(true)
.with_ansi(false)
.with_writer(CaptureWriter(error_sink.clone()))
.with_filter(build_error_filter("info")),
)
};
}
let emit = || {
tracing::info!("i5015-sibling-info");
tracing::warn!("i5015-sibling-warn");
};
if with_global_rate_filter {
let pass_all = tracing_subscriber::filter::DynFilterFn::new(|_meta, _cx| true);
let (main_layer, error_layer) = layers!();
let subscriber = Registry::default()
.with(pass_all)
.with(main_layer)
.with(error_layer);
tracing::subscriber::with_default(subscriber, emit);
} else {
let (main_layer, error_layer) = layers!();
let subscriber = Registry::default().with(main_layer).with(error_layer);
tracing::subscriber::with_default(subscriber, emit);
}
let shape = if with_global_rate_filter {
"rate-limited (release) shape"
} else {
"plain (debug/test) shape"
};
let main = String::from_utf8(main_sink.lock().unwrap().clone()).unwrap();
let error = String::from_utf8(error_sink.lock().unwrap().clone()).unwrap();
assert!(
main.contains("i5015-sibling-info"),
"[{shape}] the error layer's WARN floor must not suppress INFO on the \
main layer: {main}"
);
assert!(
main.contains("i5015-sibling-warn"),
"[{shape}] the main layer must still receive WARN: {main}"
);
assert!(
!error.contains("i5015-sibling-info"),
"[{shape}] the error layer must still drop INFO: {error}"
);
assert!(
error.contains("i5015-sibling-warn"),
"[{shape}] the error layer must still receive WARN: {error}"
);
}
#[test]
fn main_layer_still_receives_info_alongside_the_floored_error_layer() {
assert_error_floor_does_not_starve_main_layer(false);
}
#[test]
fn main_layer_still_receives_info_under_the_release_rate_limited_shape() {
assert_error_floor_does_not_starve_main_layer(true);
}
#[test]
fn init_tracer_wires_every_error_layer_through_build_error_filter() {
let source = include_str!("tracer.rs");
let cut = source.find(concat!("mod error_filter_", "tests {")).expect(
"the call-site pin anchors on this module's declaration; \
if it was renamed, update the anchor deliberately",
);
let production: String = source[..cut]
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let count = |needle: &str| production.matches(needle).count();
let error_layers = count(".with_writer(error_writer");
assert_eq!(
error_layers, 2,
"expected the two error-log layers (rate-limited and plain registry); \
if this changed, update the pin deliberately"
);
let floored = count(".with_filter(build_error_filter(&error_log_directives()))");
assert_eq!(
floored, error_layers,
"every freenet.error.* layer must be filtered through \
build_error_filter(&error_log_directives()); found {error_layers} error \
layers but {floored} floored filters (#5015)"
);
assert_eq!(
count(".and(LevelFilter::WARN)"),
1,
"the WARN floor must live in build_error_filter and nowhere else"
);
assert_eq!(
count(".from_env_lossy()"),
1,
"only the main/console filter may use from_env_lossy(); an error layer \
using it would inherit RUST_LOG's level again (#5015)"
);
}
}
#[cfg(test)]
mod cleanup_tests {
use super::{
LOG_DIR_MAX_BYTES, LogFamily, LogFile, cleanup_old_logs, live_file_indices,
periodic_log_prune, prune_log_files, rotating_log_family,
};
use std::fs;
use std::time::{Duration, SystemTime};
fn write_with_mtime(path: &std::path::Path, size: usize, mtime: SystemTime) {
fs::write(path, vec![b'.'; size]).unwrap();
let times = std::fs::FileTimes::new().set_modified(mtime);
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_times(times).unwrap();
}
fn log_file(path: &std::path::Path, modified: SystemTime, size: u64) -> LogFile {
let name = path.file_name().and_then(|n| n.to_str()).unwrap();
let family = rotating_log_family(name)
.unwrap_or_else(|| panic!("{name} is not a rotating log file"));
LogFile {
path: path.to_path_buf(),
modified,
size,
family,
}
}
fn size_pass_only(files: Vec<LogFile>, max_bytes: u64) {
prune_log_files(files, SystemTime::UNIX_EPOCH, max_bytes);
}
#[test]
fn size_cap_deletes_oldest_first_until_under_limit() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let oldest = dir.path().join("freenet.2026-05-25-12.log");
let middle = dir.path().join("freenet.2026-05-25-13.log");
let newest = dir.path().join("freenet.2026-05-25-14.log");
let t_old = now - Duration::from_secs(3600);
let t_mid = now - Duration::from_secs(60);
let t_new = now - Duration::from_secs(30);
write_with_mtime(&oldest, 4096, t_old);
write_with_mtime(&middle, 4096, t_mid);
write_with_mtime(&newest, 4096, t_new);
size_pass_only(
vec![
log_file(&oldest, t_old, 4096),
log_file(&middle, t_mid, 4096),
log_file(&newest, t_new, 4096),
],
8192,
);
assert!(
!oldest.exists(),
"oldest file should be deleted by size cap"
);
assert!(middle.exists(), "middle file should survive");
assert!(newest.exists(), "newest file should survive");
}
#[test]
fn size_cap_is_noop_when_under_limit() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let small = dir.path().join("freenet.2026-05-25-15.log");
write_with_mtime(&small, 1024, now);
size_pass_only(vec![log_file(&small, now, 1024)], 1024 * 1024 * 1024);
assert!(small.exists(), "file under cap must survive");
}
#[test]
fn time_pass_removes_files_older_than_retention() {
let dir = tempfile::tempdir().unwrap();
let ancient = dir.path().join("freenet.2026-02-14-00.log");
let recent = dir.path().join("freenet.2026-05-25-14.log");
write_with_mtime(
&ancient,
1024,
SystemTime::now() - Duration::from_secs(100 * 24 * 3600),
);
write_with_mtime(&recent, 1024, SystemTime::now());
cleanup_old_logs(dir.path());
assert!(
!ancient.exists(),
"ancient file must be removed by age pass"
);
assert!(recent.exists(), "the live file must survive");
}
#[test]
fn cleanup_ignores_non_freenet_files() {
let dir = tempfile::tempdir().unwrap();
let other = dir.path().join("other.log");
fs::write(&other, b"unrelated").unwrap();
cleanup_old_logs(dir.path());
assert!(other.exists(), "non-freenet files must not be touched");
}
#[test]
fn size_cap_preserves_most_recently_modified_file() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let live = dir.path().join("freenet.2026-05-25-18.log");
write_with_mtime(&live, 16 * 1024, now);
size_pass_only(vec![log_file(&live, now, 16 * 1024)], 1024);
assert!(
live.exists(),
"live file must survive even when alone it exceeds the cap"
);
}
#[test]
fn size_cap_deletes_oldest_but_keeps_live() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let old = dir.path().join("freenet.2026-05-25-12.log");
let live = dir.path().join("freenet.2026-05-25-18.log");
let t_old = now - Duration::from_secs(3600);
write_with_mtime(&old, 4096, t_old);
write_with_mtime(&live, 4096, now);
size_pass_only(
vec![log_file(&old, t_old, 4096), log_file(&live, now, 4096)],
5120,
);
assert!(!old.exists(), "older file must be deleted");
assert!(live.exists(), "live file must survive");
}
#[test]
fn cleanup_skips_legacy_bare_freenet_log_names() {
let dir = tempfile::tempdir().unwrap();
let bare = dir.path().join("freenet.log");
let bare_err = dir.path().join("freenet.error.log");
let scratch = dir.path().join("freenet.error.log.last");
for p in [&bare, &bare_err, &scratch] {
write_with_mtime(
p,
1024,
SystemTime::now() - Duration::from_secs(30 * 24 * 3600),
);
}
cleanup_old_logs(dir.path());
assert!(
bare.exists(),
"legacy freenet.log must not be deleted (systemd-owned)"
);
assert!(
bare_err.exists(),
"legacy freenet.error.log must not be deleted (systemd-owned)"
);
assert!(
scratch.exists(),
"transient freenet.error.log.last must not be deleted (wrapper-owned)"
);
}
#[test]
fn default_budget_holds_a_day_of_a_busy_gateways_logs() {
const GATEWAY_BYTES_PER_HOUR: u64 = 20_950_000;
let hours_retained = LOG_DIR_MAX_BYTES / GATEWAY_BYTES_PER_HOUR;
assert!(
hours_retained >= 24,
"default budget of {LOG_DIR_MAX_BYTES} bytes retains only \
{hours_retained}h at the measured gateway rate of \
{GATEWAY_BYTES_PER_HOUR} B/h; an overnight incident must still \
be on disk in the morning"
);
}
#[test]
fn size_cap_engages_exactly_at_the_budget_boundary() {
let cap = LOG_DIR_MAX_BYTES;
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let hour_ago = now - Duration::from_secs(3600);
let old = dir.path().join("freenet.2026-05-25-12.log");
let live = dir.path().join("freenet.2026-05-25-13.log");
let live_size = cap / 2;
let old_size = cap - live_size + 1; write_with_mtime(&old, 0, hour_ago);
write_with_mtime(&live, 0, now);
size_pass_only(
vec![
log_file(&old, hour_ago, old_size),
log_file(&live, now, live_size),
],
cap,
);
assert!(!old.exists(), "cap+1 must delete the oldest non-live file");
assert!(live.exists(), "live file must always survive");
write_with_mtime(&old, 0, hour_ago);
size_pass_only(
vec![
log_file(&old, hour_ago, cap - live_size),
log_file(&live, now, live_size),
],
cap,
);
assert!(old.exists(), "total == cap must NOT delete anything");
assert!(live.exists(), "live file must survive at the boundary");
}
#[test]
fn size_cap_spares_the_live_file_of_both_appenders() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let main_old_at = now - Duration::from_secs(3 * 3600);
let error_live_at = now - Duration::from_secs(2 * 3600);
let main_live_at = now - Duration::from_secs(3600);
let main_old = dir.path().join("freenet.2026-05-25-12.log");
let error_live = dir.path().join("freenet.error.2026-05-25-13.log");
let main_live = dir.path().join("freenet.2026-05-25-14.log");
write_with_mtime(&main_old, 4096, main_old_at);
write_with_mtime(&error_live, 4096, error_live_at);
write_with_mtime(&main_live, 4096, main_live_at);
size_pass_only(
vec![
log_file(&main_old, main_old_at, 4096),
log_file(&error_live, error_live_at, 4096),
log_file(&main_live, main_live_at, 4096),
],
5120,
);
assert!(
!main_old.exists(),
"the one evictable (non-live) file must be deleted"
);
assert!(
error_live.exists(),
"the error appender's open file must survive even though it is not \
the newest file overall and the directory is still over the cap"
);
assert!(
main_live.exists(),
"the main appender's open file must survive"
);
}
#[test]
fn time_pass_spares_a_live_file_whose_mtime_has_aged_out() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let error_abandoned = dir.path().join("freenet.error.2026-05-17-00.log");
let error_live = dir.path().join("freenet.error.2026-05-21-00.log");
let main_live = dir.path().join("freenet.2026-05-25-14.log");
write_with_mtime(
&error_abandoned,
1024,
now - Duration::from_secs(200 * 3600),
);
write_with_mtime(&error_live, 1024, now - Duration::from_secs(100 * 3600));
write_with_mtime(&main_live, 1024, now);
cleanup_old_logs(dir.path());
assert!(
error_live.exists(),
"the error appender's OPEN file must survive the age pass even \
though its mtime is older than the retention horizon — it is \
frozen only because nothing has been logged at WARN+ since"
);
assert!(
!error_abandoned.exists(),
"a genuinely superseded file of the same family must still be \
swept, or the age pass would never reclaim anything"
);
assert!(main_live.exists(), "the main appender's open file survives");
}
#[test]
fn prune_log_files_sorts_before_choosing_victims() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let at = |hours_ago: u64| now - Duration::from_secs(hours_ago * 3600);
let m1 = dir.path().join("freenet.2026-05-25-10.log");
let e1 = dir.path().join("freenet.error.2026-05-25-11.log");
let m2 = dir.path().join("freenet.2026-05-25-12.log");
let e2 = dir.path().join("freenet.error.2026-05-25-13.log");
let m3 = dir.path().join("freenet.2026-05-25-14.log");
for (path, hours) in [(&m1, 5), (&e1, 4), (&m2, 3), (&e2, 2), (&m3, 1)] {
write_with_mtime(path, 1024, at(hours));
}
size_pass_only(
vec![
log_file(&m3, at(1), 1024),
log_file(&e1, at(4), 1024),
log_file(&m1, at(5), 1024),
log_file(&e2, at(2), 1024),
log_file(&m2, at(3), 1024),
],
2048,
);
assert!(
m3.exists(),
"the newest Main file is the open one and must survive however \
the caller ordered the input"
);
assert!(
e2.exists(),
"the newest Error file is the open one and must survive however \
the caller ordered the input"
);
for (path, name) in [(&m1, "m1"), (&e1, "e1"), (&m2, "m2")] {
assert!(!path.exists(), "{name} is evictable and must be deleted");
}
}
#[test]
fn live_pick_breaks_mtime_ties_by_name() {
let dir = tempfile::tempdir().unwrap();
let tied = SystemTime::now();
let older = dir.path().join("freenet.error.2026-05-25-12.log");
let newer = dir.path().join("freenet.error.2026-05-25-13.log");
write_with_mtime(&older, 1024, tied);
write_with_mtime(&newer, 1024, tied);
size_pass_only(
vec![log_file(&newer, tied, 1024), log_file(&older, tied, 1024)],
1024,
);
assert!(
newer.exists(),
"the later rotation stamp is the open file and must survive the tie"
);
assert!(
!older.exists(),
"the superseded file must be the one collected"
);
}
#[test]
fn live_file_exemption_clears_once_the_appender_rotates() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let error_a = dir.path().join("freenet.error.2026-05-21-00.log");
let main_live = dir.path().join("freenet.2026-05-25-14.log");
write_with_mtime(&error_a, 1024, now - Duration::from_secs(100 * 3600));
write_with_mtime(&main_live, 1024, now);
cleanup_old_logs(dir.path());
assert!(
error_a.exists(),
"while it is the newest of its family it is the open file, so it \
is exempt however old its frozen mtime looks"
);
let error_b = dir.path().join("freenet.error.2026-05-25-14.log");
write_with_mtime(&error_b, 1024, now);
cleanup_old_logs(dir.path());
assert!(
!error_a.exists(),
"once superseded, the previously-exempt file must be collected \
on the very next prune — the exemption is positional, not \
sticky, which is what bounds it without a TTL"
);
assert!(error_b.exists(), "the new open file inherits the exemption");
assert!(main_live.exists(), "the main appender's open file survives");
}
#[test]
fn live_file_indices_is_the_newest_of_every_family_present() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let at = |secs| base + Duration::from_secs(secs);
let files = vec![
log_file(&dir.path().join("freenet.2026-05-25-10.log"), at(0), 1),
log_file(
&dir.path().join("freenet.error.2026-05-25-11.log"),
at(10),
1,
),
log_file(&dir.path().join("freenet.2026-05-25-12.log"), at(20), 1),
log_file(
&dir.path().join("freenet.error.2026-05-25-13.log"),
at(30),
1,
),
log_file(&dir.path().join("freenet.2026-05-25-14.log"), at(40), 1),
];
let mut live = live_file_indices(&files);
live.sort_unstable();
assert_eq!(
live,
vec![3, 4],
"expected the newest Error (index 3) and the newest Main (index 4)"
);
assert_eq!(live.len(), 2);
let families: Vec<LogFamily> = live.iter().map(|&i| files[i].family).collect();
assert!(families.contains(&LogFamily::Main));
assert!(families.contains(&LogFamily::Error));
}
#[test]
fn error_logs_classify_as_their_own_family() {
assert_eq!(
rotating_log_family("freenet.2026-05-25-14.log"),
Some(LogFamily::Main)
);
assert_eq!(
rotating_log_family("freenet.error.2026-05-25-14.log"),
Some(LogFamily::Error)
);
for foreign in [
"freenet.log",
"freenet.error.log",
"freenet.error.log.last",
"other.log",
"known_good_binary",
"update_probation.json",
"known_bad_version",
] {
assert_eq!(
rotating_log_family(foreign),
None,
"{foreign} must not be claimed by the log pruner"
);
}
}
#[test]
fn appenders_must_not_delegate_pruning_to_max_log_files() {
let needle = concat!(".max_log", "_files(");
let source: String = include_str!("tracer.rs")
.chars()
.filter(|c| !c.is_whitespace())
.collect();
assert!(
!source.contains(needle),
"tracer.rs must not call the rolling appender's max-log-files \
builder method: its prefix match spans both log families and the \
systemd-owned bare files. Prune via cleanup_old_logs instead."
);
}
#[tokio::test(start_paused = true)]
async fn periodic_prune_deletes_oldest_first_and_keeps_live() {
let dir = tempfile::tempdir().unwrap();
let now = SystemTime::now();
let old_ts = SystemTime::now() - Duration::from_secs(100 * 24 * 3600);
let oldest = dir.path().join("freenet.2026-02-14-00.log");
let middle = dir.path().join("freenet.2026-02-14-01.log");
let live = dir.path().join("freenet.2026-05-25-14.log");
write_with_mtime(&oldest, 4096, old_ts);
write_with_mtime(&middle, 4096, old_ts + Duration::from_secs(3600));
write_with_mtime(&live, 4096, now);
let handle = tokio::spawn(periodic_log_prune(dir.path().to_path_buf()));
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(76)).await;
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(3601)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
handle.abort();
assert!(!oldest.exists(), "oldest file must be pruned by the loop");
assert!(!middle.exists(), "middle file must be pruned by the loop");
assert!(live.exists(), "live (newest) file must be preserved");
}
}