use crate::prelude::*;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Directory,
}
#[derive(Debug, Clone)]
pub struct Entry {
pub path: PathBuf,
pub group: String,
pub bytes: u64,
pub modified: std::time::SystemTime,
pub keeps_parent: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Usage {
pub total_bytes: u64,
pub managed_bytes: u64,
pub unmanaged_bytes: u64,
pub entries: usize,
pub by_group: BTreeMap<String, u64>,
}
fn read_dir_sorted(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
paths
}
fn measure(path: &Path) -> (u64, std::time::SystemTime) {
let mut bytes = 0u64;
let mut newest = std::time::UNIX_EPOCH;
let mut stack = vec![path.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(meta) = std::fs::symlink_metadata(¤t) else {
continue;
};
if meta.is_dir() {
if let Ok(children) = std::fs::read_dir(¤t) {
stack.extend(children.flatten().map(|c| c.path()));
}
continue;
}
if let Ok(modified) = meta.modified() {
if modified > newest {
newest = modified;
}
}
bytes += meta.len();
}
(bytes, newest)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SweepReport {
pub removed: Vec<RemovedEntry>,
pub freed_bytes: u64,
pub remaining_bytes: u64,
pub protected: usize,
pub still_over: bool,
pub unreachable_bytes: u64,
pub must_free_bytes: u64,
pub space_error: Option<String>,
pub enforced_limit: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemovedEntry {
pub path: String,
pub group: String,
pub bytes: u64,
}
pub fn protects(entry: &Path, protect: &[PathBuf]) -> bool {
is_protected(entry, protect)
}
fn is_protected(entry: &Path, protect: &[PathBuf]) -> bool {
protect
.iter()
.any(|held| held == entry || held.starts_with(entry) || entry.starts_with(held))
}
fn remove_entry(entry: &Entry) -> bool {
let removed = if entry.path.is_dir() {
std::fs::remove_dir_all(&entry.path)
} else {
std::fs::remove_file(&entry.path)
};
if let Err(error) = removed {
tracing::warn!(path = %entry.path.display(), %error, "could not remove a log entry");
return false;
}
if let Some(parent) = entry.path.parent().filter(|_| !entry.keeps_parent) {
if parent.file_name().is_some() {
let _ = std::fs::remove_dir(parent);
}
}
true
}
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else if value >= 100.0 {
format!("{value:.0} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
pub const FREE_SPACE_FLOOR: u64 = 1024 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VolumeSpace {
pub available: u64,
pub total: u64,
}
#[cfg(unix)]
pub fn volume_space(path: &Path) -> Result<VolumeSpace, String> {
use std::os::unix::ffi::OsStrExt;
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|_| format!("{} cannot be asked about: it contains a NUL", path.display()))?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let code = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
if code != 0 {
return Err(format!(
"cannot read free space on the volume holding {}: {}",
path.display(),
std::io::Error::last_os_error()
));
}
let block = if stat.f_frsize > 0 {
stat.f_frsize
} else {
stat.f_bsize
} as u64;
Ok(VolumeSpace {
available: (stat.f_bavail as u64).saturating_mul(block),
total: (stat.f_blocks as u64).saturating_mul(block),
})
}
#[cfg(windows)]
pub fn volume_space(path: &Path) -> Result<VolumeSpace, String> {
use std::os::windows::ffi::OsStrExt;
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
if wide.contains(&0) {
return Err(format!(
"{} cannot be asked about: it contains a NUL",
path.display()
));
}
wide.push(0);
#[link(name = "kernel32")]
extern "system" {
fn GetDiskFreeSpaceExW(
lpDirectoryName: *const u16,
lpFreeBytesAvailableToCaller: *mut u64,
lpTotalNumberOfBytes: *mut u64,
lpTotalNumberOfFreeBytes: *mut u64,
) -> i32;
}
let mut available: u64 = 0;
let mut total: u64 = 0;
let mut free: u64 = 0;
let ok = unsafe {
GetDiskFreeSpaceExW(wide.as_ptr(), &mut available, &mut total, &mut free)
};
if ok == 0 {
return Err(format!(
"cannot read free space on the volume holding {}: {}",
path.display(),
std::io::Error::last_os_error()
));
}
Ok(VolumeSpace { available, total })
}
#[cfg(not(any(unix, windows)))]
pub fn volume_space(path: &Path) -> Result<VolumeSpace, String> {
Err(format!(
"free space on the volume holding {} cannot be read on this platform",
path.display()
))
}
pub fn must_free_for(path: &Path, floor: u64) -> (u64, Option<String>) {
match volume_space(path).map(|space| space.available) {
Ok(available) => (floor.saturating_sub(available), None),
Err(error) => (0, Some(error)),
}
}
pub fn sweep_entries(
mut entries: Vec<Entry>,
max_bytes: u64,
minimum: u64,
must_free: u64,
unreachable: u64,
protect: &[PathBuf],
dry_run: bool,
) -> SweepReport {
let managed: u64 = entries.iter().map(|e| e.bytes).sum();
let total = managed.saturating_add(unreachable);
let configured = max_bytes.max(minimum);
let limit = configured.min(total.saturating_sub(must_free));
let mut report = SweepReport {
remaining_bytes: total,
must_free_bytes: must_free,
unreachable_bytes: unreachable,
enforced_limit: limit,
..Default::default()
};
if total <= limit {
return report;
}
entries.sort_by_key(|e| e.modified);
let mut remaining = total;
for entry in entries {
if remaining <= limit {
break;
}
if is_protected(&entry.path, protect) {
report.protected += 1;
continue;
}
if !dry_run && !remove_entry(&entry) {
continue;
}
remaining = remaining.saturating_sub(entry.bytes);
report.freed_bytes += entry.bytes;
report.removed.push(RemovedEntry {
path: entry.path.display().to_string(),
group: entry.group,
bytes: entry.bytes,
});
}
report.remaining_bytes = remaining;
report.still_over = remaining > limit;
report
}
pub fn entry(path: PathBuf, group: &str) -> Entry {
let (bytes, modified) = measure(&path);
Entry {
path,
group: group.to_string(),
bytes,
modified,
keeps_parent: false,
}
}
pub fn entry_keeping_its_parent(path: PathBuf, group: &str) -> Entry {
Entry { keeps_parent: true, ..entry(path, group) }
}
pub fn usage_of(root: &Path, entries: &[Entry], covered: &[PathBuf]) -> Usage {
let mut by_group: BTreeMap<String, u64> = BTreeMap::new();
let mut managed = 0u64;
for entry in entries {
*by_group.entry(entry.group.clone()).or_default() += entry.bytes;
managed += entry.bytes;
}
let mut unmanaged = 0u64;
for path in read_dir_sorted(root) {
if covered.iter().any(|c| c == &path) {
continue;
}
if entries.iter().any(|e| e.path == path || e.path.starts_with(&path)) {
continue;
}
unmanaged += measure(&path).0;
}
Usage {
total_bytes: managed + unmanaged,
managed_bytes: managed,
unmanaged_bytes: unmanaged,
entries: entries.len(),
by_group,
}
}
pub fn children(dir: &Path) -> Vec<PathBuf> {
read_dir_sorted(dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test11404_the_volume_can_be_asked_how_much_room_it_has() {
let dir = tempfile::tempdir().expect("a temporary directory");
let available = volume_space(dir.path()).map(|space| space.available).unwrap_or_else(|error| {
panic!(
"free space is unreadable on this platform ({error}), so the \
free-space floor cannot be enforced here — which is how a \
guest reached 0 bytes free with a limiter running"
)
});
assert!(
available > 0,
"the volume holding {} reports {available} bytes free while a \
directory was just created on it",
dir.path().display()
);
let (must_free, error) = must_free_for(dir.path(), FREE_SPACE_FLOOR);
assert!(error.is_none(), "the floor could not be evaluated: {error:?}");
if available >= FREE_SPACE_FLOOR {
assert_eq!(must_free, 0, "a volume above the floor needs nothing freed");
}
}
}