use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use users::os::unix::UserExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub daemon: Option<DaemonConfig>,
pub monitored: MonitoredConfig,
pub logging: LoggingConfig,
pub cache: Option<CacheConfig>,
pub watchdog: Option<WatchdogConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
pub debug: Option<bool>,
pub metrics_interval: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitoredConfig {
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub path: Option<PathBuf>,
pub keep_days: Option<u32>,
pub size: Option<String>,
pub disk_min_free: Option<String>,
pub local_time: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
pub dir_capacity: Option<u64>,
pub dir_ttl_secs: Option<u64>,
pub file_size_capacity: Option<usize>,
pub proc_ttl_secs: Option<u64>,
pub channel_capacity: Option<usize>,
pub subscribe_buf: Option<usize>,
pub buffer_size: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchdogConfig {
pub interval_secs: Option<u64>,
pub multiplier: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ResolvedCacheConfig {
pub dir_capacity: u64,
pub dir_ttl_secs: u64,
pub file_size_capacity: usize,
pub proc_ttl_secs: u64,
pub buffer_size: usize,
pub channel_capacity: Option<usize>,
pub subscribe_buf: usize,
}
impl Default for ResolvedCacheConfig {
fn default() -> Self {
Self {
dir_capacity: crate::common::fid_parser::DIR_CACHE_CAP,
dir_ttl_secs: crate::common::fid_parser::DIR_CACHE_TTL_SECS,
file_size_capacity: crate::common::fid_parser::FILE_SIZE_CACHE_CAP,
proc_ttl_secs: crate::common::proc_cache::PROC_STORE_TTL_SECS,
buffer_size: 4096 * 8, channel_capacity: None, subscribe_buf: 4096,
}
}
}
impl CacheConfig {
pub fn resolve_with_cli(&self, cli: &CliCacheOverride) -> ResolvedCacheConfig {
let mut r = ResolvedCacheConfig::default();
if let Some(v) = self.dir_capacity {
r.dir_capacity = v;
}
if let Some(v) = self.dir_ttl_secs {
r.dir_ttl_secs = v;
}
if let Some(v) = self.file_size_capacity {
r.file_size_capacity = v;
}
if let Some(v) = self.proc_ttl_secs {
r.proc_ttl_secs = v;
}
if let Some(v) = self.channel_capacity {
r.channel_capacity = Some(v);
}
if let Some(v) = self.subscribe_buf {
r.subscribe_buf = v;
}
if let Some(v) = self.buffer_size {
r.buffer_size = v;
}
if let Some(v) = cli.dir_capacity {
r.dir_capacity = v;
}
if let Some(v) = cli.dir_ttl_secs {
r.dir_ttl_secs = v;
}
if let Some(v) = cli.file_size_capacity {
r.file_size_capacity = v;
}
if let Some(v) = cli.proc_ttl_secs {
r.proc_ttl_secs = v;
}
if let Some(v) = cli.buffer_size {
r.buffer_size = v;
}
if let Some(v) = cli.channel_capacity {
r.channel_capacity = Some(v);
}
if let Some(v) = self.subscribe_buf {
r.subscribe_buf = v;
}
if let Some(v) = cli.subscribe_buf {
r.subscribe_buf = v;
}
r
}
}
#[derive(Debug, Clone, Default)]
pub struct CliCacheOverride {
pub dir_capacity: Option<u64>,
pub dir_ttl_secs: Option<u64>,
pub file_size_capacity: Option<usize>,
pub proc_ttl_secs: Option<u64>,
pub buffer_size: Option<usize>,
pub channel_capacity: Option<usize>,
pub subscribe_buf: Option<usize>,
}
pub fn resolve_uid_gid() -> (u32, u32) {
if let Ok(uid_str) = std::env::var("SUDO_UID")
&& let Ok(uid) = uid_str.parse::<u32>()
{
let gid = std::env::var("SUDO_GID")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
return (uid, gid);
}
if nix::unistd::geteuid().is_root()
&& let Ok(home) = std::env::var("HOME")
&& let Ok(meta) = std::fs::metadata(&home)
{
use std::os::linux::fs::MetadataExt;
return (meta.st_uid(), meta.st_gid());
}
(
nix::unistd::geteuid().as_raw(),
nix::unistd::getegid().as_raw(),
)
}
pub fn chown_to_original_user(path: &Path) {
let _ = crate::common::fid_parser::chown_to_user(path);
}
pub fn resolve_uid() -> u32 {
resolve_uid_gid().0
}
pub fn resolve_home(uid: u32) -> Result<PathBuf> {
let user = users::get_user_by_uid(uid)
.ok_or_else(|| anyhow::anyhow!("User not found for UID {}", uid))?;
let home = user.home_dir().to_path_buf();
if home.as_os_str().is_empty() {
anyhow::bail!("Home directory not set for UID {}", uid);
}
Ok(home)
}
pub fn guess_home() -> String {
let uid_str = match std::env::var("SUDO_UID") {
Ok(s) => s,
Err(_) => return std::env::var("HOME").unwrap_or_else(|_| "/root".into()),
};
let uid = match uid_str.parse::<u32>() {
Ok(u) => u,
Err(_) => return std::env::var("HOME").unwrap_or_else(|_| "/root".into()),
};
if nix::unistd::geteuid().as_raw() != 0 {
return std::env::var("HOME").unwrap_or_else(|_| "/root".into());
}
match resolve_home(uid) {
Ok(p) => p.to_string_lossy().into_owned(),
Err(_) => std::env::var("HOME").unwrap_or_else(|_| "/root".into()),
}
}
pub fn expand_tilde(path: &Path, home: &str) -> PathBuf {
let s = path.to_string_lossy();
if let Some(rest) = s.strip_prefix('~')
&& (rest.is_empty() || rest.starts_with('/'))
{
return PathBuf::from(format!("{}{}", home, rest));
}
path.to_path_buf()
}
impl Default for Config {
fn default() -> Self {
Config {
daemon: None,
monitored: MonitoredConfig {
path: PathBuf::from("~/.local/share/fsmon/monitored.jsonl"),
},
logging: LoggingConfig {
path: Some(PathBuf::from("~/.local/state/fsmon")),
keep_days: None,
size: None,
disk_min_free: None,
local_time: None,
},
cache: None,
watchdog: None,
}
}
}
impl Config {
pub fn user_path() -> PathBuf {
let home = guess_home();
let xdg_config =
std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| format!("{}/.config", home));
PathBuf::from(xdg_config).join("fsmon").join("fsmon.toml")
}
pub fn path() -> PathBuf {
Self::user_path()
}
pub fn load() -> Result<Self> {
let p = Self::path();
if !p.exists() {
return Ok(Config::default());
}
let content = fs::read_to_string(&p)
.with_context(|| format!("Failed to read config {}", p.display()))?;
if is_comment_only(&content) {
return Ok(Config::default());
}
match toml::from_str::<Config>(&content) {
Ok(cfg) => Ok(cfg),
Err(e) => bail!("Invalid config file at {}: {}", p.display(), e),
}
}
pub fn resolve_paths(&mut self) -> Result<()> {
let home = guess_home();
self.monitored.path = expand_tilde(&self.monitored.path, &home);
if let Some(ref mut p) = self.logging.path {
*p = expand_tilde(p, &home);
}
Ok(())
}
pub fn ensure_monitored_dir() -> Result<()> {
let mut cfg = Config::load()?;
cfg.resolve_paths()?;
let parent = cfg
.monitored
.path
.parent()
.context("Monitored file path has no parent")?
.to_path_buf();
if !parent.exists() {
fs::create_dir_all(&parent).with_context(|| {
format!("Failed to create monitored directory: {}", parent.display())
})?;
chown_to_original_user(&parent);
}
Ok(())
}
pub fn init_dirs() -> Result<()> {
let config_path = Self::path();
if !config_path.exists() {
Self::create_default_config(&config_path)?;
} else {
eprintln!("Exists config: {}", config_path.display());
}
Ok(())
}
fn default_commented_toml() -> String {
r#"## ================================================================
## fsmon configuration file
## ================================================================
##
## All settings are optional. Commented values show defaults.
## Uncomment to override. Changes take effect on next daemon start.
## CLI flags override config file values.
##
## Legend:
## ## = descriptive comment (documentation)
## # = commented-out option (remove one # to enable)
[monitored]
## Monitored paths database file path.
## Config-only (no CLI flag).
path = "~/.local/share/fsmon/monitored.jsonl"
## ----------------------------------------------------------------
## Daemon runtime settings.
## ----------------------------------------------------------------
# [daemon]
## Enable debug output (event matching, routing decisions).
## Default: false. CLI: --debug
# debug = false
## Metrics report interval in seconds.
## When set to N > 0, prints a one-line status report to stderr
## every N seconds (uptime, RSS, caches, readers).
## Default: disabled (0). CLI: --metrics-interval SECS
# metrics_interval = 0
[logging]
## Log file output directory. Remove this section to disable file logging.
## Config-only (no CLI flag).
path = "~/.local/state/fsmon"
## Auto-clean: keep entries for at most N days.
## Config-only (clean command accepts -t/--time per invocation).
# keep_days = 30
## Auto-clean: truncate log file when it exceeds this size.
## Config-only (clean command accepts -s/--size per invocation).
# size = ">=1GB"
## Warn when free disk space drops below this threshold.
## Format: percentage ("10%") or absolute ("5GB").
## Default: no check. CLI: --logging-disk-free 10%
# disk_min_free = "10%"
## Use local time instead of UTC in event timestamps.
## Default: false (UTC). CLI: --logging-local-time
# local_time = false
## ----------------------------------------------------------------
## Cache settings. Uncomment to override defaults.
## ----------------------------------------------------------------
# [cache]
## Directory handle cache capacity.
## Each entry is ~150-200 bytes. Lower on memory-constrained systems.
## Default: 100000. CLI: --cache-dir-cap N
# dir_capacity = 100000
## Directory handle cache TTL in seconds.
## Shorter = faster memory reclaim for volatile directories.
## Default: 3600. CLI: --cache-dir-ttl SECS
# dir_ttl_secs = 3600
## File size cache capacity.
## Raise for high-file-volume workloads.
## Default: 10000. CLI: --cache-file-size N
# file_size_capacity = 10000
## Process cache TTL in seconds.
## Applies to proc_cache and pid_tree.
## Default: 600. CLI: --cache-proc-ttl SECS
# proc_ttl_secs = 600
## Fanotify read buffer size in bytes.
## Raise for high-throughput event volumes.
## Default: 32768. CLI: --cache-buffer BYTES
# buffer_size = 32768
## Event channel capacity between reader tasks and main loop.
## Default: unbounded. CLI: --cache-channel N
# channel_capacity = 1024
## Subscribe event stream buffer capacity.
## Events buffered for slow subscribers before dropping oldest.
## Default: 4096. CLI: --cache-subscribe N
# subscribe_buf = 4096
## ----------------------------------------------------------------
## systemd watchdog integration.
## Sends periodic WATCHDOG=1 to prevent systemd from restarting.
## ----------------------------------------------------------------
# [watchdog]
## Heartbeat interval in seconds.
## Must be > 0 to enable watchdog. Default: disabled.
## CLI: --watchdog-interval SECS
# interval_secs = 15
## Timeout multiplier. WatchdogSec = interval_secs × multiplier.
## MUST be > 1 (daemon refuses to start otherwise).
## Recommended: 2-4. Higher = more tolerant of transient stalls.
## Default: 2. CLI: --watchdog-multiplier N
# multiplier = 2
"#
.to_string()
}
fn create_default_config(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
chown_to_original_user(parent);
}
fs::write(path, Self::default_commented_toml())?;
chown_to_original_user(path);
eprintln!("Created config: {}", path.display());
Ok(())
}
}
fn is_comment_only(s: &str) -> bool {
s.lines().all(|l| {
let t = l.trim();
t.is_empty() || t.starts_with('#')
})
}
#[cfg(test)]
#[path = "config/tests.rs"]
mod tests;