Files
rustfs/crates/obs/src/cleaner/mod.rs
T
houseme 3b139e5267 fix(obs/cleaner): harden log cleaner durability, symlink safety, and retention (audit OLC-01..14) (#4776)
* fix(obs/cleaner): fsync archive and log dir before deleting source logs

OLC-01: the compression path flushed the BufWriter but never synced the
archive data or the parent directory before renaming, and the source was
then unlinked with no durability barrier. A crash after rename but before
the page cache reached disk could leave a truncated/zero-length archive
while the source was already gone — permanent log/audit data loss. Because
the archive is always renamed to a brand-new name (guaranteed by the
existing exists() guard), ext4 auto_da_alloc does not mask this.

Hand the underlying File back from the writer closure, sync_all() it before
rename, fsync the parent directory, and fsync the log directory after the
unlinks so a delete cannot be reordered ahead of the archive it justified.
Guard the temp file with an RAII cleanup so an early return or panic cannot
leak a *.tmp orphan.

Ref: rustfs/backlog#1194 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): create compression temp file with O_EXCL and O_NOFOLLOW

OLC-03: the temp archive was opened with File::create at a predictable
`<source>.gz.tmp` path with no O_EXCL/O_NOFOLLOW, so an actor with write
access to the log directory could pre-plant that path as a symlink and have
the compressor follow it — truncating and overwriting an arbitrary external
file, then chmod-ing it to the source log's mode. This mirrors the symlink
refusal already enforced on the deletion path (secure_delete).

Route temp creation through create_tmp_archive(), which uses create_new
(O_CREAT|O_EXCL) to refuse a pre-existing entry and, on Unix, O_NOFOLLOW to
refuse a symlink at the final path component.

Ref: rustfs/backlog#1196 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): create compression temp file with restrictive mode

OLC-06: File::create left the temp archive world-readable (0644 & ~umask)
for the entire duration of compressing a large log, exposing the full
plaintext of a possibly-0600 audit log on shared hosts until the mode was
copied only after the write completed. Pass the source mode into
create_tmp_archive and open the temp file with it (default 0600) so it is
restrictive from creation; the post-write chmod still tightens/matches the
source mode exactly.

Ref: rustfs/backlog#1199 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): validate existing archive before skipping recompression

OLC-02: the idempotency guard used Path::exists() (which follows symlinks)
and trusted whatever it found, then the caller deleted the source. A planted
`<archive>.gz` symlink, or a zero-length/truncated archive left by a crashed
run (OLC-01), would green-light deleting the source with no valid backup —
data loss / log destruction.

Replace exists() with symlink_metadata (no follow) and only treat the entry
as a completed prior result when it is a regular, non-empty file whose header
matches the codec magic (gzip 1f 8b / zstd 28 b5 2f fd). Anything else falls
through to recompression, whose atomic create_new+rename replaces the bad
entry (a symlink is replaced, never followed or deleted through).

Ref: rustfs/backlog#1195 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): stop dry-run overstating reclaimed bytes for compression

OLC-08: in dry-run, compress_with_writer returned output_bytes = 0, so
projected_freed_bytes = input and delete_files reported the full input as
freed. A real run keeps the archive on disk (freed = input - archive), so
dry-run overstated reclaim by the whole archive footprint. Estimate the
archive with a deliberately conservative ratio so the projection never
exceeds what a real run reclaims.

Ref: rustfs/backlog#1201 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): make freed-byte accounting resilient; document steal metric

OLC-12: input/output byte sizes were read via metadata().unwrap_or(0), which
silently reports 0 on failure and skews freed-byte metrics (input - 0 = full
input, overstating reclaim). Use the copy() byte count as the authoritative
input size and, when the archive metadata read fails, conservatively assume
no savings instead of 0. Also document that the steal_success_rate counts
only victim steals (batch = one success), so it reads as a relative
rebalancing signal, not absolute task acquisition.

Ref: rustfs/backlog#1205 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): preserve level-0 semantics, allow zstd 22, log effective levels

OLC-09: build() and the codec calls clamped gzip/zstd levels to [1,9]/[1,21],
silently rewriting gzip level 0 (store) and zstd level 0 (codec default) to 1
and blocking the legal zstd maximum of 22. Clamp to [0,9]/[0,22] so those
meanings survive, and echo the effective (post-clamp) levels in the startup
log via new effective_gzip_level()/effective_zstd_level() getters so the log
matches what actually runs.

Ref: rustfs/backlog#1202 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): bound compressed archives by byte cap; warn on retention=0

OLC-04: archive expiry was gated on compressed_file_retention_days > 0, so
retention=0 disabled it entirely while compression kept producing archives,
and max_total_size_bytes only ever bounded uncompressed logs — unbounded disk
growth. Replace select_expired_compressed with select_archives_to_delete,
which applies age expiry (when retention is on) and, regardless of retention,
trims the oldest archives until the set fits under max_total_size_bytes. Also
warn at startup when compression is on with retention=0 so the "keep forever"
semantics are not mistaken for "delete immediately".

Ref: rustfs/backlog#1197 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): warn on invalid exclude glob instead of dropping silently

OLC-05: build() dropped unparseable exclude globs via filter_map(...ok()), so
a typo (or a literal comma splitting a char-class in the config string) turned
"protect this file" into "delete this file" with no signal. Log a warning per
rejected pattern with the raw string and parse error so the misconfiguration
is visible.

Ref: rustfs/backlog#1198 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* perf(obs/cleaner): backoff idle workers, cap worker count, lower small-host floor

OLC-11: the work-stealing loop re-spun on Steal::Retry with no yield and used
yield_now on the empty path, burning CPU during redistribution windows;
worker_count had no upper bound so a mis-set parallel_workers over a directory
of thousands of logs could spawn thousands of threads; and
default_parallel_workers forced >=4 workers even on 1-2 vCPU hosts. Use
crossbeam_utils::Backoff (spin->yield, reset on work) on the idle paths, clamp
worker_count to MAX_PARALLEL_COMPRESS_WORKERS, and lower the default floor to 1.

Ref: rustfs/backlog#1204 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): warn when active-file guard is disabled by empty filename

OLC-13 (defense-in-depth): the scanner protects the live log purely by exact
filename equality against active_filename. An empty active_filename silently
disables that protection, so a non-empty file_pattern could make the live log
a deletion candidate via the public builder. Warn in build() when that unsafe
combination is configured. The audit's "never delete the newest match"
structural guard is intentionally not implemented: it would conflict with the
legitimate keep_files=0 semantics (purge all rotated logs). The naming
contract is instead locked by regression tests (OLC-14).

Ref: rustfs/backlog#1206 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): warn on unknown algorithm/match_mode, echo match_mode

OLC-07: from_config_str silently fell back to defaults for unrecognized
compression algorithm and match mode (any non-"prefix" value became Suffix),
hiding operator typos like "prefixx" that could make the cleaner match no
rotated logs. Warn on a non-empty unrecognized value in both parsers, and
echo the resolved match_mode in the startup log alongside the algorithm.

Ref: rustfs/backlog#1200 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): derive orphan .tmp suffixes and exempt them from min age

OLC-10: orphan `*.gz.tmp`/`*.zst.tmp` cleanup was gated by min_file_age_seconds
(default 3600), so crash-left orphans lingered up to an hour, and the tmp
suffix list was hardcoded rather than derived from compressed_suffixes() — a
new codec would leave `*.<ext>.tmp` orphans the scanner never recognizes.
Derive the temp suffix from CompressionAlgorithm::compressed_suffixes(), and
gate orphan removal on a small fixed grace window instead of min_file_age
(orphans are never live-written after the rename that would promote them).

Ref: rustfs/backlog#1203 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(obs/cleaner): cover symlink, archive expiry, idempotency, and edge cases

OLC-14: add regression tests for the previously-untested safety/correctness
branches — symlink rejection (external target never deleted), archive age
expiry vs fresh retention, archive byte-cap trim with retention disabled,
gz/zst classification, max_single_file_size selection, min_age protecting a
fresh non-empty log, active-file exclusion when the active name also matches
the pattern, invalid exclude glob not aborting build, dry-run + compression
creating no archive, gzip round-trip validity, and the idempotent-archive
branch trusting a valid prior archive.

Ref: rustfs/backlog#1207 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* style(obs/cleaner): apply rustfmt and collapse nested if (clippy)

Formatting-only cleanup over the audit fix series: rustfmt normalization of the
multi-line expressions introduced in compress.rs/core.rs, plus collapsing the
delete_files directory-fsync into a single let-chain to satisfy
clippy::collapsible_if. No behavior change.

Co-Authored-By: heihutu <heihutu@gmail.com>

* refactor(obs/cleaner): collapse redundant source stats in compression path

The per-issue fixes to compress_with_writer accumulated three metadata()
syscalls on the source in the real compression path: an input_bytes read that
was immediately shadowed by the copied byte count (a dead read), a source_mode
read (OLC-06), and the pre-OLC-06 post-write chmod re-reading the same mode.
Collapse to a single fd-based read — move the dry-run input_bytes read into
the dry-run branch, read source_mode from the already-open fd (no path stat,
no TOCTOU), and reuse it for the post-write chmod. Behavior is unchanged
(same inode's mode, written for input size); 3 source stats -> 1.

Co-Authored-By: heihutu <heihutu@gmail.com>

* chore(obs/cleaner): fix typo flagged by CI (mis-set -> misconfigured)

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 12:01:52 +00:00

582 lines
23 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Log-file cleanup subsystem.
//!
//! This module exposes the high-level [`LogCleaner`] API used by the
//! observability layer to manage rotated log files after they are no longer
//! active. The implementation is intentionally split into small focused
//! sub-modules so each concern can evolve independently without turning the
//! cleaner into a monolithic state machine.
//!
//! At a high level, one cleanup pass follows this lifecycle:
//!
//! 1. scan the target directory and classify matching entries;
//! 2. decide which regular log files exceed retention constraints;
//! 3. optionally compress those files using the configured codec;
//! 4. delete only files that are safe to remove;
//! 5. separately expire already-compressed archives by age.
//!
//! The design favors operational safety over aggressive reclamation:
//! compression and deletion are decoupled, symlinks are rejected on removal,
//! and dry-run mode reports intent without mutating the filesystem.
//!
//! ## Sub-modules
//!
//! | Module | Responsibility |
//! |-------------|----------------------------------------------------------|
//! | `types` | Shared enums and metadata (`FileInfo`, match/compression choices) |
//! | `scanner` | Filesystem traversal, pattern matching, empty-file handling |
//! | `compress` | Archive creation helpers for gzip and zstd |
//! | `core` | Selection, parallel/serial processing, secure deletion |
//!
//! ## Usage
//!
//! ```no_run
//! use std::path::PathBuf;
//! use rustfs_obs::LogCleaner;
//! use rustfs_obs::types::FileMatchMode;
//!
//! let cleaner = LogCleaner::builder(
//! PathBuf::from("/var/log/rustfs"),
//! "rustfs.log.".to_string(),
//! "rustfs.log".to_string(),
//! )
//! .match_mode(FileMatchMode::Prefix)
//! .keep_files(10)
//! .max_total_size_bytes(2 * 1024 * 1024 * 1024) // 2 GiB
//! .max_single_file_size_bytes(0) // unlimited
//! .compress_old_files(true)
//! .gzip_compression_level(6)
//! .compressed_file_retention_days(30)
//! .exclude_patterns(vec![])
//! .delete_empty_files(true)
//! .min_file_age_seconds(3600) // 1 hour
//! .dry_run(false)
//! .build();
//!
//! let (deleted, freed_bytes) = cleaner.cleanup().expect("cleanup failed");
//! println!("Deleted {deleted} files, freed {freed_bytes} bytes");
//! ```
mod compress;
mod core;
mod scanner;
pub mod types;
/// Primary entry point for the cleaner subsystem.
///
/// Re-exported from [`core`] so callers can construct a cleaner without
/// knowing the internal module layout.
pub use core::LogCleaner;
#[cfg(test)]
mod tests {
use super::core::LogCleaner;
use super::scanner;
use super::types::{CompressionAlgorithm, FileMatchMode};
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use tempfile::TempDir;
/// Create a test log file with deterministic contents and size.
fn create_log_file(dir: &Path, name: &str, size: usize) -> std::io::Result<()> {
let path = dir.join(name);
let mut f = File::create(path)?;
f.write_all(&vec![b'X'; size])?;
f.flush()
}
/// Build a cleaner with sensible test defaults (no compression, no age gate).
fn make_cleaner(dir: std::path::PathBuf, keep: usize, max_bytes: u64) -> LogCleaner {
LogCleaner::builder(dir, "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(keep)
.max_total_size_bytes(max_bytes)
.min_file_age_seconds(0) // 0 = no age gate in tests
.delete_empty_files(true)
.build()
}
fn make_parallel_compress_cleaner(dir: std::path::PathBuf, workers: usize) -> LogCleaner {
LogCleaner::builder(dir, "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(0)
.compress_old_files(true)
.parallel_compress(true)
.parallel_workers(workers)
.min_file_age_seconds(0)
.delete_empty_files(true)
.build()
}
fn assert_parallel_cleanup_completes(file_count: usize, workers: usize) -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
for i in 0..file_count {
create_log_file(&dir, &format!("app.log.2024-01-{i:03}"), 256)?;
}
let cleaner = make_parallel_compress_cleaner(dir.clone(), workers);
let (tx, rx) = mpsc::sync_channel(1);
std::thread::spawn(move || {
let _ = tx.send(cleaner.cleanup());
});
let result = rx
.recv_timeout(Duration::from_secs(5))
.expect("parallel cleanup should finish without blocking on the bounded results channel")?;
let compressed_suffixes = CompressionAlgorithm::compressed_suffixes();
let archive_count = std::fs::read_dir(&dir)?
.filter_map(Result::ok)
.filter(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
compressed_suffixes.iter().any(|suffix| name.ends_with(suffix))
})
.count();
let original_count = std::fs::read_dir(&dir)?
.filter_map(Result::ok)
.filter(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
name.starts_with("app.log.") && !compressed_suffixes.iter().any(|suffix| name.ends_with(suffix))
})
.count();
let archive_bytes: u64 = std::fs::read_dir(&dir)?
.filter_map(Result::ok)
.filter(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
compressed_suffixes.iter().any(|suffix| name.ends_with(suffix))
})
.map(|entry| entry.metadata().map(|metadata| metadata.len()).unwrap_or(0))
.sum();
assert_eq!(result.0, file_count, "all rotated logs should be deleted after compression");
assert_eq!(
result.1,
(file_count * 256) as u64 - archive_bytes,
"freed bytes should exclude archive bytes that still remain on disk"
);
assert_eq!(archive_count, file_count, "each rotated log should leave behind one archive");
assert_eq!(original_count, 0, "compressed source logs should be removed");
Ok(())
}
#[test]
fn test_cleanup_removes_oldest_when_over_size() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01", 1024)?;
create_log_file(&dir, "app.log.2024-01-02", 1024)?;
create_log_file(&dir, "app.log.2024-01-03", 1024)?;
create_log_file(&dir, "other.log", 1024)?; // not managed
// Total managed = 3 072 bytes; limit = 2 048; keep_files = 2 → must delete 1.
let cleaner = make_cleaner(dir, 2, 2048);
let (deleted, freed) = cleaner.cleanup()?;
assert_eq!(deleted, 1, "should delete exactly one file");
assert_eq!(freed, 1024);
Ok(())
}
#[test]
fn test_cleanup_respects_keep_files() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
for i in 1..=5 {
create_log_file(&dir, &format!("app.log.2024-01-0{i}"), 1024)?;
}
let cleaner = make_cleaner(dir, 3, 0);
let (deleted, _) = cleaner.cleanup()?;
// Updated expectation: keep_files acts as a limit (ceiling), so excess files are deleted.
assert_eq!(deleted, 2, "keep_files should enforce a maximum file count");
Ok(())
}
#[test]
fn test_cleanup_ignores_unrelated_files() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01", 1024)?;
create_log_file(&dir, "app.log.2024-01-02", 1024)?;
create_log_file(&dir, "other.log", 512)?; // different prefix
// keep_files=1 and max_bytes=1500: deleting one managed file (1024 bytes) leaves
// a single managed file of 1024 bytes, which satisfies both the file-count and
// size limits. "other.log" (different prefix) must never be touched.
let cleaner = make_cleaner(dir.clone(), 1, 1500);
let (deleted, _) = cleaner.cleanup()?;
// "other.log" must not be counted or deleted; only 1 managed file removed.
assert_eq!(deleted, 1, "only managed files should be deleted");
assert!(dir.join("other.log").exists(), "unrelated file must not be deleted");
Ok(())
}
#[test]
fn test_collect_log_files_counts_correctly() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01", 1024)?;
create_log_file(&dir, "app.log.2024-01-02", 2048)?;
create_log_file(&dir, "other.log", 512)?;
let result = scanner::scan_log_directory(&dir, "app.log.", Some("app.log"), FileMatchMode::Prefix, &[], 0, true, false)?;
assert_eq!(result.logs.len(), 2, "scanner should find exactly 2 managed files");
Ok(())
}
#[test]
fn test_dry_run_does_not_delete() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01", 1024)?;
create_log_file(&dir, "app.log.2024-01-02", 1024)?;
create_log_file(&dir, "app.log.2024-01-03", 1024)?;
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(1)
.max_total_size_bytes(1024)
.dry_run(true)
.build();
let (deleted, _freed) = cleaner.cleanup()?;
// dry_run=true reports deletions but doesn't actually remove files.
assert!(deleted > 0, "dry_run should report files as deleted");
assert_eq!(std::fs::read_dir(&dir)?.count(), 3, "no files should actually be removed");
Ok(())
}
#[test]
fn test_cleanup_suffix_matching() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "2026-03-01-06-21.rustfs.log", 1024)?;
create_log_file(&dir, "2026-03-01-06-22.rustfs.log", 1024)?;
create_log_file(&dir, "other.log", 1024)?; // not managed
let cleaner = LogCleaner::builder(dir, ".rustfs.log".to_string(), "current.log".to_string())
.match_mode(FileMatchMode::Suffix)
.keep_files(1)
.max_total_size_bytes(1024)
.build();
let (deleted, freed) = cleaner.cleanup()?;
assert_eq!(deleted, 1, "should delete exactly one file");
assert_eq!(freed, 1024);
Ok(())
}
#[test]
fn test_parallel_cleanup_handles_more_results_than_channel_capacity() -> std::io::Result<()> {
assert_parallel_cleanup_completes(12, 4)
}
#[test]
fn test_parallel_cleanup_handles_results_within_channel_capacity() -> std::io::Result<()> {
assert_parallel_cleanup_completes(6, 4)
}
// ---- OLC-14: safety/correctness regression coverage ----
use std::time::SystemTime;
fn set_mtime_ago(path: &Path, ago: Duration) {
let when = SystemTime::now() - ago;
File::options()
.write(true)
.open(path)
.expect("open for mtime")
.set_modified(when)
.expect("set mtime");
}
#[cfg(unix)]
#[test]
fn symlink_matching_pattern_is_never_deleted() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
let outside = TempDir::new()?;
let target = outside.path().join("precious.txt");
std::fs::write(&target, b"precious")?;
// A symlink named to match the managed pattern.
std::os::unix::fs::symlink(&target, dir.join("app.log.2024-01-01"))?;
create_log_file(&dir, "app.log.2024-01-02", 1024)?;
// keep_files=0 would delete every managed *regular* file.
let cleaner = make_cleaner(dir.clone(), 0, 0);
let _ = cleaner.cleanup()?;
assert!(target.exists(), "external symlink target must never be deleted");
assert!(
dir.join("app.log.2024-01-01").exists(),
"symlink entry must survive (skipped via symlink_metadata, not followed)"
);
Ok(())
}
#[test]
fn expired_archives_deleted_fresh_kept() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01.gz", 100)?;
create_log_file(&dir, "app.log.2024-01-02.gz", 100)?;
set_mtime_ago(&dir.join("app.log.2024-01-01.gz"), Duration::from_secs(2 * 24 * 3600));
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(100)
.compressed_file_retention_days(1)
.min_file_age_seconds(0)
.build();
let (deleted, _) = cleaner.cleanup()?;
assert_eq!(deleted, 1, "only the aged archive should expire");
assert!(!dir.join("app.log.2024-01-01.gz").exists());
assert!(dir.join("app.log.2024-01-02.gz").exists());
Ok(())
}
#[test]
fn archives_bounded_by_byte_cap_when_retention_disabled() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01.gz", 100)?;
create_log_file(&dir, "app.log.2024-01-02.gz", 100)?;
// Age the first so the oldest-first byte-cap trim evicts it.
set_mtime_ago(&dir.join("app.log.2024-01-01.gz"), Duration::from_secs(100));
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(100)
.compressed_file_retention_days(0) // retention disabled
.max_total_size_bytes(150) // 200 total > 150 -> trim the oldest
.min_file_age_seconds(0)
.build();
let (deleted, _) = cleaner.cleanup()?;
assert_eq!(deleted, 1, "byte cap must bound archives even with retention off");
assert!(!dir.join("app.log.2024-01-01.gz").exists(), "oldest archive trimmed");
assert!(dir.join("app.log.2024-01-02.gz").exists());
Ok(())
}
#[test]
fn gz_and_zst_files_classified_as_archives() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01.gz", 100)?;
create_log_file(&dir, "app.log.2024-01-02.zst", 100)?;
create_log_file(&dir, "app.log.2024-01-03", 100)?; // plain log
let result = scanner::scan_log_directory(&dir, "app.log.", Some("app.log"), FileMatchMode::Prefix, &[], 0, false, false)?;
assert_eq!(result.compressed_archives.len(), 2, "gz and zst are archives");
assert_eq!(result.logs.len(), 1, "only the plain file is a regular log");
Ok(())
}
#[test]
fn max_single_file_size_deletes_only_oversized() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01", 100)?;
create_log_file(&dir, "app.log.2024-01-02", 100)?;
create_log_file(&dir, "app.log.2024-01-03", 5000)?;
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(3)
.max_total_size_bytes(0)
.max_single_file_size_bytes(1000)
.min_file_age_seconds(0)
.build();
let (deleted, _) = cleaner.cleanup()?;
assert_eq!(deleted, 1);
assert!(!dir.join("app.log.2024-01-03").exists(), "oversized file removed");
assert!(dir.join("app.log.2024-01-01").exists());
assert!(dir.join("app.log.2024-01-02").exists());
Ok(())
}
#[test]
fn min_age_protects_fresh_nonempty_log() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01", 1024)?;
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(0)
.min_file_age_seconds(3600)
.build();
let (deleted, _) = cleaner.cleanup()?;
assert_eq!(deleted, 0, "fresh non-empty log younger than min age must be untouched");
assert!(dir.join("app.log.2024-01-01").exists());
Ok(())
}
#[test]
fn active_file_matching_pattern_is_protected() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
// The active file both equals active_filename AND matches the suffix pattern.
create_log_file(&dir, "current.rustfs.log", 1024)?;
create_log_file(&dir, "old.rustfs.log", 1024)?;
let cleaner = LogCleaner::builder(dir.clone(), ".rustfs.log".to_string(), "current.rustfs.log".to_string())
.match_mode(FileMatchMode::Suffix)
.keep_files(0)
.min_file_age_seconds(0)
.build();
let _ = cleaner.cleanup()?;
assert!(dir.join("current.rustfs.log").exists(), "active file must never be deleted");
assert!(!dir.join("old.rustfs.log").exists(), "non-active rotated log removed");
Ok(())
}
#[test]
fn invalid_exclude_glob_does_not_abort_build() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.keep", 100)?;
create_log_file(&dir, "app.log.drop", 100)?;
// "[invalid" is dropped with a warning; "*keep*" is a valid exclude.
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(0)
.exclude_patterns(vec!["[invalid".to_string(), "*keep*".to_string()])
.min_file_age_seconds(0)
.build();
let _ = cleaner.cleanup()?;
assert!(dir.join("app.log.keep").exists(), "valid exclude must still protect its file");
assert!(!dir.join("app.log.drop").exists(), "non-excluded rotated log removed");
Ok(())
}
#[test]
fn dry_run_with_compression_creates_no_archive() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.2024-01-01", 1024)?;
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(0)
.compress_old_files(true)
.compression_algorithm(CompressionAlgorithm::Gzip)
.parallel_compress(false)
.min_file_age_seconds(0)
.dry_run(true)
.build();
let (deleted, freed) = cleaner.cleanup()?;
assert!(deleted > 0, "dry-run reports intended deletions");
assert!(freed > 0, "dry-run reports projected freed bytes");
assert!(dir.join("app.log.2024-01-01").exists(), "dry-run must not delete");
assert!(!dir.join("app.log.2024-01-01.gz").exists(), "dry-run must not create an archive");
Ok(())
}
#[test]
fn gzip_archive_round_trips_and_source_removed() -> std::io::Result<()> {
use std::io::Read;
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
let content = vec![b'A'; 4096];
{
let mut f = File::create(dir.join("app.log.2024-01-01"))?;
f.write_all(&content)?;
f.flush()?;
}
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(0)
.compress_old_files(true)
.compression_algorithm(CompressionAlgorithm::Gzip)
.parallel_compress(false)
.min_file_age_seconds(0)
.build();
let _ = cleaner.cleanup()?;
assert!(!dir.join("app.log.2024-01-01").exists(), "source removed after compression");
let gz = dir.join("app.log.2024-01-01.gz");
assert!(gz.exists(), "archive created");
let mut decoded = Vec::new();
flate2::read::GzDecoder::new(File::open(&gz)?).read_to_end(&mut decoded)?;
assert_eq!(decoded, content, "archive must decompress to the original bytes");
Ok(())
}
#[test]
fn idempotent_archive_branch_trusts_valid_archive() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
let content = vec![b'B'; 2048];
let write_source = || -> std::io::Result<()> {
let mut f = File::create(dir.join("app.log.2024-01-01"))?;
f.write_all(&content)?;
f.flush()
};
write_source()?;
let build = || {
LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(0)
.compress_old_files(true)
.compression_algorithm(CompressionAlgorithm::Gzip)
.parallel_compress(false)
.min_file_age_seconds(0)
.build()
};
// Pass 1: create the archive and delete the source.
let _ = build().cleanup()?;
let gz = dir.join("app.log.2024-01-01.gz");
assert!(gz.exists());
let archive_len = std::fs::metadata(&gz)?.len();
// Recreate the source; pass 2 must trust the existing valid archive and
// delete the source again without rewriting the archive.
write_source()?;
let (deleted, _) = build().cleanup()?;
assert_eq!(deleted, 1);
assert!(!dir.join("app.log.2024-01-01").exists(), "source deleted via idempotent branch");
assert_eq!(std::fs::metadata(&gz)?.len(), archive_len, "existing valid archive unchanged");
Ok(())
}
}