use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub(crate) struct SystemPolicy {
#[serde(default)]
pub(crate) storage: StoragePolicy,
#[serde(default)]
pub(crate) links: LinkPolicy,
#[serde(default)]
pub(crate) guard: GuardPolicy,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct StoragePolicy {
#[serde(default)]
pub(crate) default_root: String,
#[serde(default = "default_min_size_mb")]
pub(crate) min_size_mb: u64,
#[serde(default = "default_kinds")]
pub(crate) kinds: String,
}
impl Default for StoragePolicy {
fn default() -> Self {
Self {
default_root: String::new(),
min_size_mb: default_min_size_mb(),
kinds: default_kinds(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub(crate) struct LinkPolicy {
#[serde(default)]
pub(crate) entries: Vec<LinkEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct LinkEntry {
pub(crate) link: String,
pub(crate) target: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct GuardPolicy {
#[serde(default = "default_enabled")]
pub(crate) enabled: bool,
#[serde(default = "default_disk_mount_path")]
pub(crate) disk_mount_path: String,
#[serde(default = "default_guard_interval_secs")]
pub(crate) interval_secs: u64,
#[serde(default = "default_disk_early_warn_percent")]
pub(crate) disk_early_warn_percent: u8,
#[serde(default = "default_disk_warn_percent")]
pub(crate) disk_warn_percent: u8,
#[serde(default = "default_disk_action_percent")]
pub(crate) disk_action_percent: u8,
#[serde(default = "default_disk_critical_percent")]
pub(crate) disk_critical_percent: u8,
#[serde(default = "default_false")]
pub(crate) freeze_sync_at_action: bool,
#[serde(default = "default_sync_freeze_marker")]
pub(crate) sync_freeze_marker: String,
#[serde(default = "default_unfreeze_below_percent")]
pub(crate) unfreeze_below_percent: u8,
#[serde(default = "default_process_cpu_percent")]
pub(crate) process_cpu_percent: f32,
#[serde(default = "default_process_rss_mb")]
pub(crate) process_rss_mb: u64,
#[serde(default = "default_process_sustain_secs")]
pub(crate) process_sustain_secs: u64,
#[serde(default = "default_process_exempt_names")]
pub(crate) process_exempt_names: String,
#[serde(default = "default_reap_report_min_idle_hours")]
pub(crate) reap_report_min_idle_hours: u64,
#[serde(default = "default_reap_report_max_cpu_seconds")]
pub(crate) reap_report_max_cpu_seconds: u64,
#[serde(default = "default_reap_report_signatures")]
pub(crate) reap_report_signatures: String,
#[serde(default = "default_true")]
pub(crate) notify: bool,
#[serde(default = "default_notify_command")]
pub(crate) notify_command: String,
#[serde(default = "default_notify_cooldown_secs")]
pub(crate) notify_cooldown_secs: u64,
#[serde(default = "default_report_repeat_secs")]
pub(crate) report_repeat_secs: u64,
#[serde(default)]
pub(crate) auto_renice: bool,
#[serde(default = "default_renice_value")]
pub(crate) renice_value: i32,
#[serde(default = "default_release_after_secs")]
pub(crate) release_after_secs: u64,
#[serde(default = "default_guard_log_file")]
pub(crate) guard_log_file: String,
#[serde(default = "default_guard_log_max_mb")]
pub(crate) guard_log_max_mb: u64,
#[serde(default = "default_auto_cleanup_rust")]
pub(crate) auto_cleanup_rust: bool,
#[serde(default)]
pub(crate) auto_cleanup_apply: bool,
#[serde(default = "default_auto_cleanup_interval_secs")]
pub(crate) auto_cleanup_interval_secs: u64,
#[serde(default = "default_cleanup_min_size_mb")]
pub(crate) cleanup_min_size_mb: u64,
#[serde(default = "default_rust_search_roots")]
pub(crate) rust_search_roots: String,
#[serde(default = "default_node_modules_search_roots")]
pub(crate) node_modules_search_roots: String,
#[serde(default = "default_true")]
pub(crate) track_trends: bool,
#[serde(default = "default_trend_warn_hours")]
pub(crate) trend_warn_hours: u64,
#[serde(default = "default_true")]
pub(crate) monitor_inodes: bool,
#[serde(default = "default_inode_warn_percent")]
pub(crate) inode_warn_percent: u8,
#[serde(default = "default_true")]
pub(crate) monitor_zombies: bool,
#[serde(default = "default_zombie_threshold")]
pub(crate) zombie_threshold: u64,
#[serde(default = "default_true")]
pub(crate) monitor_memory: bool,
#[serde(default = "default_mem_available_warn_percent")]
pub(crate) mem_available_warn_percent: u8,
#[serde(default = "default_swap_used_warn_percent")]
pub(crate) swap_used_warn_percent: u8,
#[serde(default = "default_mem_psi_full_warn")]
pub(crate) mem_psi_full_warn: f64,
#[serde(default = "default_memory_pressure_sustain_secs")]
pub(crate) memory_pressure_sustain_secs: u64,
#[serde(default = "default_true")]
pub(crate) auto_renice_on_memory: bool,
#[serde(default = "default_true")]
pub(crate) bias_oom_on_pressure: bool,
#[serde(default)]
pub(crate) cap_offenders_cpu_percent: u32,
#[serde(default = "default_process_stuck_after_secs")]
pub(crate) process_stuck_after_secs: u64,
#[serde(default = "default_disk_rapid_fill_gbph")]
pub(crate) disk_rapid_fill_gbph: f64,
#[serde(default = "default_true")]
pub(crate) trash_credential_guard: bool,
#[serde(default = "default_true")]
pub(crate) monitor_logs: bool,
#[serde(default = "default_log_size_mb")]
pub(crate) log_size_mb: u64,
#[serde(default)]
pub(crate) log_dirs: Option<String>,
#[serde(default)]
pub(crate) auto_truncate_logs: bool,
#[serde(default = "default_log_max_truncate_mb")]
pub(crate) log_max_truncate_mb: u64,
#[serde(default)]
pub(crate) log_preserve_header_lines: usize,
#[serde(default = "default_true")]
pub(crate) docker_prune: bool,
#[serde(default)]
pub(crate) docker_prune_volumes: bool,
#[serde(default)]
pub(crate) docker_prune_all: bool,
#[serde(default = "default_true")]
pub(crate) clean_package_caches: bool,
#[serde(default = "default_true")]
pub(crate) clean_trash: bool,
#[serde(default = "default_true")]
pub(crate) clean_nix_garbage: bool,
#[serde(default = "default_true")]
pub(crate) clean_node_modules: bool,
#[serde(default = "default_nix_keep_generations")]
pub(crate) nix_keep_generations: u32,
#[serde(default = "default_node_modules_max_age_days")]
pub(crate) node_modules_max_age_days: u64,
#[serde(default = "default_true")]
pub(crate) clean_tmp: bool,
#[serde(default = "default_tmp_search_paths")]
pub(crate) tmp_search_paths: String,
#[serde(default = "default_tmp_min_age_hours")]
pub(crate) tmp_min_age_hours: u64,
#[serde(default = "default_trash_min_age_days")]
pub(crate) trash_min_age_days: u64,
#[serde(default)]
pub(crate) protected_paths: Vec<String>,
#[serde(default = "default_proactive_cleanup_percent")]
pub(crate) proactive_cleanup_percent: u8,
#[serde(default = "default_rust_target_max_age_days")]
pub(crate) rust_target_max_age_days: u64,
#[serde(default = "default_rust_target_action_min_age_days")]
pub(crate) rust_target_action_min_age_days: u64,
#[serde(default = "default_proactive_cleanup_interval_cycles")]
pub(crate) proactive_cleanup_interval_cycles: u64,
#[serde(default)]
pub(crate) disk_extra_mounts: String,
#[serde(default = "default_quarantine_dir")]
pub(crate) quarantine_dir: String,
#[serde(default = "default_quarantine_ttl_days")]
pub(crate) quarantine_ttl_days: u64,
#[serde(default = "default_quarantine_expire_interval_secs")]
pub(crate) quarantine_expire_interval_secs: u64,
#[serde(default)]
pub(crate) clean_quarantine_first: bool,
#[serde(default = "default_true")]
pub(crate) auto_relocate: bool,
#[serde(default)]
pub(crate) auto_relocate_apply: bool,
#[serde(default = "default_relocate_candidate_roots")]
pub(crate) relocate_candidate_roots: String,
#[serde(default)]
pub(crate) relocate_cold_root: String,
#[serde(default = "default_relocate_min_size_mb")]
pub(crate) relocate_min_size_mb: u64,
#[serde(default = "default_relocate_min_age_days")]
pub(crate) relocate_min_age_days: u64,
#[serde(default = "default_relocate_max_moves_per_pass")]
pub(crate) relocate_max_moves_per_pass: u64,
}
impl Default for GuardPolicy {
fn default() -> Self {
Self {
enabled: default_enabled(),
disk_mount_path: default_disk_mount_path(),
interval_secs: default_guard_interval_secs(),
disk_early_warn_percent: default_disk_early_warn_percent(),
disk_warn_percent: default_disk_warn_percent(),
disk_action_percent: default_disk_action_percent(),
disk_critical_percent: default_disk_critical_percent(),
freeze_sync_at_action: default_false(),
sync_freeze_marker: default_sync_freeze_marker(),
unfreeze_below_percent: default_unfreeze_below_percent(),
process_cpu_percent: default_process_cpu_percent(),
process_rss_mb: default_process_rss_mb(),
process_sustain_secs: default_process_sustain_secs(),
process_exempt_names: default_process_exempt_names(),
reap_report_min_idle_hours: default_reap_report_min_idle_hours(),
reap_report_max_cpu_seconds: default_reap_report_max_cpu_seconds(),
reap_report_signatures: default_reap_report_signatures(),
notify: default_true(),
notify_command: default_notify_command(),
notify_cooldown_secs: default_notify_cooldown_secs(),
report_repeat_secs: default_report_repeat_secs(),
auto_renice: false,
renice_value: default_renice_value(),
release_after_secs: default_release_after_secs(),
guard_log_file: default_guard_log_file(),
guard_log_max_mb: default_guard_log_max_mb(),
auto_cleanup_rust: default_auto_cleanup_rust(),
auto_cleanup_apply: false,
auto_cleanup_interval_secs: default_auto_cleanup_interval_secs(),
cleanup_min_size_mb: default_cleanup_min_size_mb(),
rust_search_roots: default_rust_search_roots(),
node_modules_search_roots: default_node_modules_search_roots(),
track_trends: default_true(),
trend_warn_hours: default_trend_warn_hours(),
monitor_inodes: default_true(),
inode_warn_percent: default_inode_warn_percent(),
monitor_zombies: default_true(),
zombie_threshold: default_zombie_threshold(),
monitor_memory: default_true(),
mem_available_warn_percent: default_mem_available_warn_percent(),
swap_used_warn_percent: default_swap_used_warn_percent(),
mem_psi_full_warn: default_mem_psi_full_warn(),
memory_pressure_sustain_secs: default_memory_pressure_sustain_secs(),
auto_renice_on_memory: default_true(),
bias_oom_on_pressure: default_true(),
cap_offenders_cpu_percent: 0,
process_stuck_after_secs: default_process_stuck_after_secs(),
disk_rapid_fill_gbph: default_disk_rapid_fill_gbph(),
trash_credential_guard: default_true(),
monitor_logs: default_true(),
log_size_mb: default_log_size_mb(),
log_dirs: None,
auto_truncate_logs: false,
log_max_truncate_mb: default_log_max_truncate_mb(),
log_preserve_header_lines: 0,
docker_prune: default_true(),
docker_prune_volumes: false,
docker_prune_all: false,
clean_package_caches: default_true(),
clean_trash: default_true(),
clean_nix_garbage: default_true(),
clean_node_modules: default_true(),
nix_keep_generations: 5,
node_modules_max_age_days: default_node_modules_max_age_days(),
clean_tmp: default_true(),
tmp_search_paths: default_tmp_search_paths(),
tmp_min_age_hours: default_tmp_min_age_hours(),
trash_min_age_days: default_trash_min_age_days(),
protected_paths: Vec::new(),
proactive_cleanup_percent: default_proactive_cleanup_percent(),
rust_target_max_age_days: default_rust_target_max_age_days(),
rust_target_action_min_age_days: default_rust_target_action_min_age_days(),
proactive_cleanup_interval_cycles: default_proactive_cleanup_interval_cycles(),
disk_extra_mounts: String::new(),
quarantine_dir: default_quarantine_dir(),
quarantine_ttl_days: default_quarantine_ttl_days(),
quarantine_expire_interval_secs: default_quarantine_expire_interval_secs(),
clean_quarantine_first: false,
auto_relocate: default_true(),
auto_relocate_apply: false,
relocate_candidate_roots: default_relocate_candidate_roots(),
relocate_cold_root: String::new(),
relocate_min_size_mb: default_relocate_min_size_mb(),
relocate_min_age_days: default_relocate_min_age_days(),
relocate_max_moves_per_pass: default_relocate_max_moves_per_pass(),
}
}
}
pub(crate) fn default_min_size_mb() -> u64 {
512
}
pub(crate) fn default_kinds() -> String {
"rust-build,node-deps,build-output,cache".to_string()
}
pub(crate) fn default_true() -> bool {
true
}
pub(crate) fn default_tmp_search_paths() -> String {
"/tmp".to_string()
}
pub(crate) fn default_tmp_min_age_hours() -> u64 {
24
}
pub(crate) fn default_trash_min_age_days() -> u64 {
7
}
pub(crate) fn default_false() -> bool {
false
}
pub(crate) fn default_enabled() -> bool {
true
}
fn default_disk_mount_path() -> String {
if PathBuf::from("/nix").exists() {
"/nix".to_string()
} else {
"/".to_string()
}
}
fn default_guard_interval_secs() -> u64 {
30
}
fn default_disk_early_warn_percent() -> u8 {
70
}
pub(crate) fn default_disk_warn_percent() -> u8 {
80
}
pub(crate) fn default_disk_action_percent() -> u8 {
90
}
pub(crate) fn default_disk_critical_percent() -> u8 {
95
}
pub(crate) fn default_sync_freeze_marker() -> String {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
eprintln!("⚠️ could not determine home directory, using /var/tmp fallback");
PathBuf::from("/var/tmp")
}
};
home.join(".dracon")
.join("dracon-sync.freeze")
.display()
.to_string()
}
fn default_unfreeze_below_percent() -> u8 {
88
}
pub(crate) fn default_process_cpu_percent() -> f32 {
50.0
}
pub(crate) fn default_process_rss_mb() -> u64 {
4096
}
pub(crate) fn default_process_sustain_secs() -> u64 {
30
}
pub(crate) fn default_reap_report_min_idle_hours() -> u64 {
crate::reap::DEFAULT_REAP_REPORT_MIN_IDLE_HOURS
}
pub(crate) fn default_reap_report_max_cpu_seconds() -> u64 {
crate::reap::DEFAULT_REAP_REPORT_MAX_CPU_SECONDS
}
pub(crate) fn default_reap_report_signatures() -> String {
crate::reap::DEFAULT_REAP_REPORT_SIGNATURES.to_string()
}
pub(crate) fn default_process_exempt_names() -> String {
"systemd,dbus-daemon,Xorg,kwin_wayland,plasmashell,dracon-code".to_string()
}
pub(crate) fn default_notify_command() -> String {
let user = std::env::var("USER").unwrap_or_else(|_| "dracon".to_string());
let candidates = [
format!("/etc/profiles/per-user/{}/bin/notify-send", user),
"/run/current-system/sw/bin/notify-send".to_string(),
"/usr/bin/notify-send".to_string(),
];
for path in &candidates {
if std::path::Path::new(path).exists() {
return path.clone();
}
}
"/usr/bin/notify-send".to_string()
}
pub(crate) const NOTIFY_FORBIDDEN_PROGRAMS: &[&str] = &[
"sh",
"bash",
"dash",
"zsh",
"ksh",
"mksh",
"csh",
"tcsh",
"fish",
"ash",
"busybox",
"sudo",
"su",
"doas",
"pkexec",
"setuidgid",
"env",
"xargs",
"nohup",
"setsid",
"nice",
"ionice",
"timeout",
"watch",
"stdbuf",
"systemd-run",
"flatpak-run",
"chroot",
"unshare",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NotifyCommand {
pub(crate) program: String,
pub(crate) args: Vec<String>,
}
pub(crate) fn split_command_words(input: &str) -> Result<Vec<String>, String> {
let mut words: Vec<String> = Vec::new();
let mut current = String::new();
let mut has_word = false;
let mut chars = input.chars();
while let Some(c) = chars.next() {
match c {
c if c.is_whitespace() => {
if has_word {
words.push(std::mem::take(&mut current));
has_word = false;
}
}
'\'' => {
has_word = true;
loop {
match chars.next() {
Some('\'') => break,
Some(inner) => current.push(inner),
None => return Err("unterminated single quote".to_string()),
}
}
}
'"' => {
has_word = true;
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(escaped @ ('"' | '\\' | '$' | '`')) => current.push(escaped),
Some('\n') => {}
Some(other) => {
current.push('\\');
current.push(other);
}
None => {
return Err("trailing backslash inside double quotes".to_string())
}
},
Some(inner) => current.push(inner),
None => return Err("unterminated double quote".to_string()),
}
}
}
'\\' => {
has_word = true;
match chars.next() {
Some('\n') => {}
Some(escaped) => current.push(escaped),
None => return Err("trailing backslash".to_string()),
}
}
plain => {
has_word = true;
current.push(plain);
}
}
}
if has_word {
words.push(current);
}
Ok(words)
}
impl NotifyCommand {
pub(crate) fn parse(raw: &str) -> Result<Self, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("notify_command is empty".to_string());
}
let mut words = split_command_words(trimmed)?;
let program = words.remove(0);
if !program.starts_with('/') {
return Err(format!(
"notify_command must start with an absolute path, got: {program}"
));
}
let file_name = std::path::Path::new(&program)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&program);
if NOTIFY_FORBIDDEN_PROGRAMS.contains(&file_name) {
return Err(format!(
"notify_command may not use `{file_name}` (shell, privilege escalator, \
or argument pass-through wrapper) — point it at a notifier binary"
));
}
Ok(Self {
program,
args: words,
})
}
}
pub(crate) fn default_notify_cooldown_secs() -> u64 {
300
}
pub(crate) fn default_report_repeat_secs() -> u64 {
1800
}
pub(crate) fn default_renice_value() -> i32 {
5
}
pub(crate) fn default_release_after_secs() -> u64 {
120
}
pub(crate) fn default_guard_log_file() -> String {
let home = match dirs::home_dir() {
Some(h) => h,
None => return String::new(),
};
home.join(".local")
.join("state")
.join("dracon")
.join("dracon-system-guard.log")
.display()
.to_string()
}
pub(crate) fn default_guard_log_max_mb() -> u64 {
1
}
pub(crate) fn default_auto_cleanup_rust() -> bool {
true
}
pub(crate) fn default_cleanup_min_size_mb() -> u64 {
256
}
pub(crate) fn default_rust_search_roots() -> String {
"~/Dev".to_string()
}
pub(crate) fn default_node_modules_search_roots() -> String {
"~/Dev".to_string()
}
fn default_trend_warn_hours() -> u64 {
24
}
fn default_inode_warn_percent() -> u8 {
85
}
fn default_zombie_threshold() -> u64 {
20
}
fn default_mem_available_warn_percent() -> u8 {
10
}
fn default_swap_used_warn_percent() -> u8 {
50
}
fn default_mem_psi_full_warn() -> f64 {
10.0
}
fn default_memory_pressure_sustain_secs() -> u64 {
120
}
fn default_process_stuck_after_secs() -> u64 {
600
}
fn default_disk_rapid_fill_gbph() -> f64 {
20.0
}
fn default_log_size_mb() -> u64 {
100
}
fn default_log_max_truncate_mb() -> u64 {
50
}
pub(crate) fn default_log_dirs() -> String {
"~/.local/state/dracon".to_string()
}
pub(crate) fn effective_log_dirs(raw: &Option<String>) -> Option<String> {
match raw {
None => Some(default_log_dirs()),
Some(value) if value.trim().is_empty() => None,
Some(value) => Some(value.clone()),
}
}
pub(crate) fn default_node_modules_max_age_days() -> u64 {
30
}
pub(crate) fn default_nix_keep_generations() -> u32 {
5
}
pub(crate) fn default_proactive_cleanup_percent() -> u8 {
80
}
pub(crate) fn default_auto_cleanup_interval_secs() -> u64 {
1800
}
pub(crate) fn default_rust_target_max_age_days() -> u64 {
14
}
pub(crate) fn default_rust_target_action_min_age_days() -> u64 {
7
}
pub(crate) fn default_proactive_cleanup_interval_cycles() -> u64 {
120
}
pub(crate) fn default_quarantine_dir() -> String {
"~/.local/share/dracon/quarantine".to_string()
}
pub(crate) fn default_quarantine_ttl_days() -> u64 {
30
}
pub(crate) fn default_quarantine_expire_interval_secs() -> u64 {
86_400
}
pub(crate) fn default_relocate_candidate_roots() -> String {
"~/Dev".to_string()
}
pub(crate) fn default_relocate_min_size_mb() -> u64 {
2048
}
pub(crate) fn default_relocate_min_age_days() -> u64 {
14
}
pub(crate) fn default_relocate_max_moves_per_pass() -> u64 {
3
}
#[cfg(test)]
pub(crate) const SENTINEL_ZERO_KNOBS: &[&str] = &[
"trend_warn_hours", "zombie_threshold",
"log_size_mb",
"node_modules_max_age_days",
"tmp_min_age_hours",
"relocate_min_age_days",
"trash_min_age_days",
"rust_target_action_min_age_days",
"quarantine_ttl_days",
"quarantine_expire_interval_secs",
"cap_offenders_cpu_percent",
"reap_report_min_idle_hours",
"reap_report_max_cpu_seconds",
"nix_keep_generations",
];
pub(crate) const LOG_PRESERVE_HEADER_LINES_MAX: usize = 10_000;
pub(crate) const RENICE_VALUE_MIN: i32 = 0;
pub(crate) const RENICE_VALUE_MAX: i32 = 19;
static LAST_REPORTED_CLAMPS: std::sync::OnceLock<std::sync::Mutex<Option<Vec<&'static str>>>> =
std::sync::OnceLock::new();
impl SystemPolicy {
pub(crate) fn normalize(&mut self) {
let mut adjusted = normalize_storage_policy(&mut self.storage);
adjusted.append(&mut normalize_guard_policy(&mut self.guard));
adjusted.sort_unstable();
report_clamps(adjusted);
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ClampReport {
SilentNoState,
Unchanged,
Clamped(Vec<&'static str>),
Recovered(Vec<&'static str>),
}
pub(crate) fn clamp_report_decision(
previous: Option<&[&'static str]>,
current: &[&'static str],
) -> ClampReport {
match (previous, current) {
(None, []) => ClampReport::SilentNoState,
(None, cur) => ClampReport::Clamped(cur.to_vec()),
(Some(prev), cur) if prev == cur => ClampReport::Unchanged,
(Some(prev), []) => ClampReport::Recovered(prev.to_vec()),
(Some(_), cur) => ClampReport::Clamped(cur.to_vec()),
}
}
fn report_clamps(adjusted: Vec<&'static str>) {
let cell = LAST_REPORTED_CLAMPS.get_or_init(|| std::sync::Mutex::new(None));
let mut last = cell.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let decision = clamp_report_decision(last.as_deref(), &adjusted);
let fields = match &decision {
ClampReport::SilentNoState | ClampReport::Unchanged => return,
ClampReport::Clamped(fields) | ClampReport::Recovered(fields) => fields.clone(),
};
*last = Some(adjusted);
if matches!(decision, ClampReport::Recovered(_)) {
eprintln!(
"✓ policy: all values now in range (previously clamped: {})",
fields.join(", ")
);
} else {
eprintln!(
"⚠ policy: out-of-range value(s) clamped to their legal range: {}",
fields.join(", ")
);
}
}
pub(crate) fn normalize_storage_policy(storage: &mut StoragePolicy) -> Vec<&'static str> {
let mut adjusted = Vec::new();
if storage.min_size_mb < 1 {
adjusted.push("storage.min_size_mb");
storage.min_size_mb = 1;
}
adjusted
}
pub(crate) fn normalize_guard_policy(policy: &mut GuardPolicy) -> Vec<&'static str> {
normalize_guard_policy_with_home(policy, dirs::home_dir().as_deref())
}
pub(crate) fn normalize_guard_policy_with_home(
policy: &mut GuardPolicy,
home: Option<&Path>,
) -> Vec<&'static str> {
let mut adjusted: Vec<&'static str> = Vec::new();
policy.protected_paths = policy
.protected_paths
.iter()
.map(|p| expand_tilde_with_home(p.trim(), home).display().to_string())
.collect();
macro_rules! floor {
($field:ident, $min:expr) => {{
let min = $min;
if policy.$field < min {
adjusted.push(stringify!($field));
policy.$field = min;
}
}};
}
macro_rules! ceil {
($field:ident, $max:expr) => {{
let max = $max;
if policy.$field > max {
adjusted.push(stringify!($field));
policy.$field = max;
}
}};
}
macro_rules! band {
($field:ident, $min:expr, $max:expr) => {{
let (min, max) = ($min, $max);
if policy.$field < min || policy.$field > max {
adjusted.push(stringify!($field));
policy.$field = policy.$field.max(min).min(max);
}
}};
}
macro_rules! fband {
($field:ident, $min:expr, $max:expr) => {{
let (min, max) = ($min, $max);
let before = policy.$field;
let after = before.max(min).min(max);
if after != before {
adjusted.push(stringify!($field));
policy.$field = after;
}
}};
}
floor!(interval_secs, 5);
floor!(auto_cleanup_interval_secs, 60);
floor!(report_repeat_secs, 60);
floor!(proactive_cleanup_interval_cycles, 1);
band!(disk_warn_percent, 1, 100);
band!(disk_early_warn_percent, 1, 100);
if policy.disk_early_warn_percent > policy.disk_warn_percent {
adjusted.push("disk_early_warn_percent");
policy.disk_early_warn_percent = policy.disk_warn_percent;
}
if policy.disk_action_percent < policy.disk_warn_percent {
adjusted.push("disk_action_percent");
policy.disk_action_percent = policy.disk_warn_percent;
}
if policy.disk_critical_percent < policy.disk_action_percent {
adjusted.push("disk_critical_percent");
policy.disk_critical_percent = policy.disk_action_percent;
}
if policy.disk_action_percent > 100 {
adjusted.push("disk_action_percent");
policy.disk_action_percent = 100;
}
if policy.disk_critical_percent > 100 {
adjusted.push("disk_critical_percent");
policy.disk_critical_percent = 100;
}
if policy.proactive_cleanup_percent >= policy.disk_action_percent {
adjusted.push("proactive_cleanup_percent");
policy.proactive_cleanup_percent = policy.disk_action_percent.saturating_sub(1).max(1);
}
band!(unfreeze_below_percent, 1, 100);
if policy.unfreeze_below_percent >= policy.disk_action_percent {
adjusted.push("unfreeze_below_percent");
policy.unfreeze_below_percent = policy.disk_action_percent.saturating_sub(1).max(1);
}
band!(inode_warn_percent, 1, 100);
band!(mem_available_warn_percent, 1, 100);
band!(swap_used_warn_percent, 1, 100);
fband!(process_cpu_percent, 1.0, 100_000.0);
floor!(process_rss_mb, 64);
floor!(process_sustain_secs, 5);
if policy.process_stuck_after_secs < policy.process_sustain_secs {
adjusted.push("process_stuck_after_secs");
policy.process_stuck_after_secs = policy.process_sustain_secs;
}
band!(renice_value, RENICE_VALUE_MIN, RENICE_VALUE_MAX);
floor!(release_after_secs, 5);
floor!(notify_cooldown_secs, 5);
ceil!(notify_cooldown_secs, 86_400);
fband!(mem_psi_full_warn, 0.0, 100.0);
floor!(memory_pressure_sustain_secs, 30);
ceil!(cap_offenders_cpu_percent, 100);
fband!(disk_rapid_fill_gbph, 0.5, 100_000.0);
floor!(guard_log_max_mb, 1);
floor!(log_max_truncate_mb, 1);
ceil!(log_preserve_header_lines, LOG_PRESERVE_HEADER_LINES_MAX);
floor!(cleanup_min_size_mb, 1);
floor!(rust_target_max_age_days, 1);
floor!(relocate_min_size_mb, 1);
floor!(relocate_max_moves_per_pass, 1);
if policy.sync_freeze_marker.trim().is_empty() {
adjusted.push("sync_freeze_marker");
policy.sync_freeze_marker = default_sync_freeze_marker();
}
if policy.quarantine_dir.trim().is_empty() {
adjusted.push("quarantine_dir");
policy.quarantine_dir = default_quarantine_dir();
}
if policy.notify_command.trim().is_empty() {
adjusted.push("notify_command");
policy.notify_command = default_notify_command();
}
adjusted
}
pub(crate) fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut idx = 0usize;
while value >= 1024.0 && idx < UNITS.len() - 1 {
value /= 1024.0;
idx += 1;
}
format!("{value:.1} {}", UNITS[idx])
}
pub(crate) fn canonical_system_root() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/home"))
.join(".dracon")
}
fn expand_tilde_with_home(raw: &str, home: Option<&Path>) -> PathBuf {
if raw == "~" {
return home
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
}
if let Some(rest) = raw.strip_prefix("~/") {
return home
.map(|home| home.join(rest))
.unwrap_or_else(|| PathBuf::from(".").join(rest));
}
PathBuf::from(raw)
}
pub(crate) fn expand_tilde(raw: &str) -> PathBuf {
expand_tilde_with_home(raw, dirs::home_dir().as_deref())
}
pub(crate) fn resolve_policy_path(raw: &str) -> PathBuf {
let expanded = expand_tilde(raw);
if expanded.is_absolute() {
return expanded;
}
match dirs::home_dir() {
Some(home) => home.join(expanded),
None => expanded,
}
}
pub(crate) fn resolve_guard_log_path(raw: &str) -> Option<PathBuf> {
resolve_guard_log_path_with_home(raw, dirs::home_dir().as_deref())
}
pub(crate) fn resolve_guard_log_path_with_home(raw: &str, home: Option<&Path>) -> Option<PathBuf> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
Some(expand_tilde_with_home(raw, home))
}
fn known_keys_of<T: Serialize + Default>(extra: &[&str]) -> HashSet<String> {
let mut keys: HashSet<String> = toml::to_string(&T::default())
.ok()
.and_then(|text| text.parse::<toml::Value>().ok())
.and_then(|v| v.as_table().cloned())
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();
keys.extend(extra.iter().map(|k| k.to_string()));
keys
}
pub(crate) const GUARD_OPTION_FIELDS: &[&str] = &["log_dirs"];
pub(crate) fn known_policy_keys() -> HashMap<&'static str, HashSet<String>> {
let mut map = HashMap::new();
map.insert("guard", known_keys_of::<GuardPolicy>(GUARD_OPTION_FIELDS));
map.insert("storage", known_keys_of::<StoragePolicy>(&[]));
map.insert("links", known_keys_of::<LinkPolicy>(&[]));
map
}
pub(crate) fn unknown_policy_keys(doc: &toml::Value) -> Vec<String> {
let Some(table) = doc.as_table() else {
return Vec::new();
};
let known = known_policy_keys();
let mut unknown = Vec::new();
for (section, value) in table {
match known.get(section.as_str()) {
None => unknown.push(section.clone()),
Some(accepted) => {
let Some(inner) = value.as_table() else {
unknown.push(section.clone());
continue;
};
for key in inner.keys() {
if !accepted.contains(key) {
unknown.push(format!("{section}.{key}"));
continue;
}
if section == "links" && key == "entries" {
unknown.extend(unknown_link_entry_keys(value));
}
}
}
}
}
unknown.sort();
unknown
}
fn known_link_entry_keys() -> HashSet<String> {
toml::to_string(&LinkEntry {
link: String::new(),
target: String::new(),
})
.ok()
.and_then(|text| text.parse::<toml::Value>().ok())
.and_then(|v| v.as_table().cloned())
.map(|t| t.keys().cloned().collect())
.unwrap_or_default()
}
fn unknown_link_entry_keys(links_value: &toml::Value) -> Vec<String> {
let Some(entries) = links_value
.as_table()
.and_then(|t| t.get("entries"))
.and_then(|e| e.as_array())
else {
return Vec::new();
};
let accepted = known_link_entry_keys();
let mut unknown = Vec::new();
for entry in entries {
let Some(table) = entry.as_table() else {
unknown.push("links.entries".to_string());
continue;
};
for key in table.keys() {
if !accepted.contains(key) {
unknown.push(format!("links.entries.{key}"));
}
}
}
unknown
}
pub(crate) fn hint_for(key: &str) -> String {
let (section, base) = match key.split_once('.') {
Some((s, b)) => (Some(s), b),
None => (None, key),
};
let known = known_policy_keys();
for (name, candidates) in &known {
if Some(*name) == section {
continue;
}
if candidates.contains(base) {
return format!(" — `{base}` belongs under [{name}]");
}
}
if section.is_none() {
let mut best = usize::MAX;
let mut close_sections: Vec<&str> = Vec::new();
for name in known.keys() {
let d = edit_distance(base, name);
if d < best {
best = d;
close_sections.clear();
}
if d == best && d <= 2 {
close_sections.push(name);
}
}
if !close_sections.is_empty() {
return format!(" — did you mean [{}]?", close_sections.join("] or ["));
}
}
let typed_tier = policy_tier(base);
let typed_family = policy_family(base);
let mut best = usize::MAX;
let mut best_same_tier = usize::MAX;
let mut close: Vec<&str> = Vec::new();
let mut close_same_tier: Vec<&str> = Vec::new();
let mut family_members: Vec<&str> = Vec::new();
for candidates in known.values() {
for candidate in candidates {
let d = edit_distance(base, candidate);
if typed_family.is_some() && policy_family(candidate) == typed_family {
family_members.push(candidate);
}
if typed_tier.is_some() && policy_tier(candidate) == typed_tier {
if d < best_same_tier {
best_same_tier = d;
close_same_tier.clear();
}
if d == best_same_tier && d <= 2 {
close_same_tier.push(candidate);
}
continue;
}
if typed_family.is_some() && policy_family(candidate) == typed_family {
continue;
}
if d < best {
best = d;
close.clear();
}
if d == best && d <= 2 {
close.push(candidate);
}
}
}
if !close_same_tier.is_empty() {
return format!(" — did you mean {}?", close_same_tier.join(" or "));
}
family_members.sort_unstable();
family_members.dedup();
if !family_members.is_empty() && family_members.len() <= 4 {
return format!(
" — did you mean one of {}? (they are different settings)",
family_members.join(", ")
);
}
if close.is_empty() {
String::new()
} else {
format!(" — did you mean {}?", close.join(" or "))
}
}
fn policy_tier(key: &str) -> Option<&str> {
if let Some(rest) = key.strip_prefix("rust_target") {
return Some(match rest {
"_max_age_days" => "rust_target_max",
"_action_min_age_days" => "rust_target_action",
_ => "rust_target_other",
});
}
for suffix in ["_min_age_days", "_max_age_days"] {
if key.ends_with(suffix) {
return Some(key);
}
}
None
}
fn policy_family(key: &str) -> Option<&str> {
if let Some(rest) = key.strip_prefix("rust_target") {
if rest.ends_with("_age_days") {
return Some("rust_target");
}
return None;
}
for suffix in ["_min_age_days", "_max_age_days"] {
if let Some(prefix) = key.strip_suffix(suffix) {
return Some(prefix);
}
}
None
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
pub(crate) fn parse_kinds(csv: &str) -> HashSet<String> {
csv.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
pub(crate) const NON_CLEANUP_KINDS: &[&str] = &["git-db"];
pub(crate) fn filter_selectable_cleanup_kinds(
requested: HashSet<String>,
) -> (HashSet<String>, Vec<String>) {
let excluded: Vec<String> = NON_CLEANUP_KINDS
.iter()
.filter(|k| requested.contains(**k))
.map(|k| (*k).to_string())
.collect();
let kept = requested
.into_iter()
.filter(|k| !NON_CLEANUP_KINDS.contains(&k.as_str()))
.collect();
(kept, excluded)
}