//! Filesystem helpers shared by the hull and the plugins that own a corner of //! the shared `/data` volume: free space, on-disk size, and the grace-period //! removal both `reclaim` passes (cache containers in the hull, `bt-temp/` in //! the torrent plugin) are built on. Moved verbatim from meta-share //! `reclaim.rs` / `eviction.rs`. use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use tracing::warn; /// Free space (bytes available to an unprivileged process) on the filesystem /// backing `path`; `None` when the query fails or the platform isn't supported. #[cfg(unix)] pub fn available_bytes(path: &Path) -> Option { use std::os::unix::ffi::OsStrExt; let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; // SAFETY: `c_path` is a valid NUL-terminated path; `statvfs` only writes // into the zeroed-out struct and returns 0 on success. let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) }; if rc != 0 { return None; } #[allow(clippy::unnecessary_cast)] Some((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64)) } #[cfg(not(unix))] pub fn available_bytes(_path: &Path) -> Option { None } /// Apparent bytes under `dir` (file lengths, subdirectories walked, symlinks /// skipped). `0` when absent. Blocking. pub fn dir_size_blocking(dir: &Path) -> u64 { let mut total = 0u64; let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { let Ok(rd) = std::fs::read_dir(&d) else { continue }; for entry in rd.flatten() { match entry.file_type() { Ok(ft) if ft.is_dir() => stack.push(entry.path()), Ok(ft) if ft.is_file() => { if let Ok(meta) = entry.metadata() { total = total.saturating_add(meta.len()); } } _ => {} } } } total } /// Bytes `path` actually occupies: allocated blocks, not apparent length (a /// streaming-only torrent's file is sparse and its length is the whole film). /// Blocking. pub fn disk_usage_blocking(path: &Path) -> u64 { let mut total = 0u64; let mut stack = vec![path.to_path_buf()]; while let Some(p) = stack.pop() { let Ok(meta) = std::fs::symlink_metadata(&p) else { continue }; if meta.is_dir() { if let Ok(rd) = std::fs::read_dir(&p) { stack.extend(rd.flatten().map(|e| e.path())); } } else if meta.is_file() { total = total.saturating_add(allocated(&meta)); } } total } #[cfg(unix)] fn allocated(meta: &std::fs::Metadata) -> u64 { use std::os::unix::fs::MetadataExt; meta.blocks().saturating_mul(512) } #[cfg(not(unix))] fn allocated(meta: &std::fs::Metadata) -> u64 { meta.len() } /// Top-level entries of `dir` that `is_owned` doesn't claim. pub async fn unowned_entries( dir: &Path, is_owned: impl Fn(&std::ffi::OsStr) -> bool, ) -> Vec<(PathBuf, OsString)> { let mut out = Vec::new(); let Ok(mut rd) = tokio::fs::read_dir(dir).await else { return out; }; while let Ok(Some(entry)) = rd.next_entry().await { let name = entry.file_name(); if !is_owned(&name) { out.push((entry.path(), name)); } } out } /// Outcome of [`remove_if_stale`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Removal { Removed(u64), TooYoung, Failed, } /// Has nothing under `path` changed for `grace`? Unreadable → `false` (leave it). pub async fn is_stale(path: &Path, grace: Duration, now: SystemTime) -> bool { let p = path.to_path_buf(); let newest = tokio::task::spawn_blocking(move || newest_change_blocking(&p)) .await .ok() .flatten(); match newest { Some(t) => now.duration_since(t).map(|age| age >= grace).unwrap_or(false), None => false, } } /// Remove a file or directory tree once nothing under it changed for `grace`. /// Returns the bytes it occupied on disk. pub async fn remove_if_stale(path: &Path, grace: Duration, now: SystemTime) -> Removal { if !grace.is_zero() && !is_stale(path, grace, now).await { return Removal::TooYoung; } let p = path.to_path_buf(); let res = tokio::task::spawn_blocking(move || { let bytes = disk_usage_blocking(&p); let r = match std::fs::symlink_metadata(&p) { Ok(m) if m.is_dir() => std::fs::remove_dir_all(&p), Ok(_) => std::fs::remove_file(&p), Err(e) => Err(e), }; r.map(|()| bytes) }) .await; match res { Ok(Ok(b)) => Removal::Removed(b), Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Removal::Removed(0), Ok(Err(e)) => { warn!(path = %path.display(), error = %e, "reclaim: removal failed"); Removal::Failed } Err(_) => Removal::Failed, } } /// The most recent modification or status change of `path` or anything under /// it. ctime matters: a container renamed from `tmp/` into `cache/` keeps its /// old mtime, and only ctime records the move. pub fn newest_change_blocking(path: &Path) -> Option { let mut newest: Option = None; let mut stack = vec![path.to_path_buf()]; while let Some(p) = stack.pop() { let Ok(meta) = std::fs::symlink_metadata(&p) else { if p == path { return None; } continue; }; for t in [meta.modified().ok(), change_time(&meta)].into_iter().flatten() { newest = Some(newest.map_or(t, |n| n.max(t))); } if meta.is_dir() { if let Ok(rd) = std::fs::read_dir(&p) { stack.extend(rd.flatten().map(|e| e.path())); } } } newest } #[cfg(unix)] fn change_time(meta: &std::fs::Metadata) -> Option { use std::os::unix::fs::MetadataExt; let secs = u64::try_from(meta.ctime()).ok()?; Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) } #[cfg(not(unix))] fn change_time(_meta: &std::fs::Metadata) -> Option { None } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn young_entries_survive_and_zero_grace_removes() { let d = std::env::temp_dir().join(format!("fsutil-{}", std::process::id())); std::fs::create_dir_all(d.join("a")).unwrap(); std::fs::write(d.join("a/f"), vec![1u8; 4096]).unwrap(); let now = SystemTime::now(); assert_eq!( remove_if_stale(&d.join("a"), Duration::from_secs(3600), now).await, Removal::TooYoung ); assert!(matches!( remove_if_stale(&d.join("a"), Duration::ZERO, now).await, Removal::Removed(_) )); assert!(!d.join("a").exists()); assert_eq!(remove_if_stale(&d.join("gone"), Duration::ZERO, now).await, Removal::Removed(0)); let _ = std::fs::remove_dir_all(&d); } #[tokio::test] async fn unowned_lists_only_unclaimed_names() { let d = std::env::temp_dir().join(format!("fsutil-own-{}", std::process::id())); std::fs::create_dir_all(d.join("keep")).unwrap(); std::fs::create_dir_all(d.join("drop")).unwrap(); let got = unowned_entries(&d, |n| n == "keep").await; assert_eq!(got.len(), 1); assert_eq!(got[0].1, "drop"); let _ = std::fs::remove_dir_all(&d); } #[test] fn sizes_of_a_missing_dir_are_zero() { assert_eq!(dir_size_blocking(Path::new("/definitely/not/here")), 0); assert_eq!(disk_usage_blocking(Path::new("/definitely/not/here")), 0); } }