use std::{ io, path::{ Path, PathBuf, }, sync::OnceLock, }; use directories::ProjectDirs; use serde::{ Deserialize, Deserializer, Serialize, }; use snafu::{ ResultExt, Snafu, }; use tracing::warn; use super::options::{ ActivePane, AppLayout, SeccompBpf, }; use crate::{ cli::keys::TuiKeyBindingsConfig, timestamp::TimestampFormat, }; /// Wrapper around `ProjectDirs` that supports overriding directories /// (e.g. when running elevated via sudo, to use the original user's dirs). #[derive(Debug, Clone)] pub struct TracexecProjectDirs { config_dir: PathBuf, data_dir: PathBuf, data_local_dir: PathBuf, } impl TracexecProjectDirs { pub fn config_dir(&self) -> &Path { &self.config_dir } pub fn data_dir(&self) -> &Path { &self.data_dir } pub fn data_local_dir(&self) -> &Path { &self.data_local_dir } } impl From for TracexecProjectDirs { fn from(dirs: ProjectDirs) -> Self { Self { config_dir: dirs.config_dir().to_path_buf(), data_dir: dirs.data_dir().to_path_buf(), data_local_dir: dirs.data_local_dir().to_path_buf(), } } } struct ProjectDirOverrides { config_dir: PathBuf, data_dir: PathBuf, data_local_dir: PathBuf, } static PROJECT_DIR_OVERRIDES: OnceLock = OnceLock::new(); /// Set overrides for the project directories. Must be called before any call to /// `project_directory()`. Used by the elevated process to point at the original /// user's config/data directories. pub fn set_project_dir_overrides(config_dir: PathBuf, data_dir: PathBuf, data_local_dir: PathBuf) { PROJECT_DIR_OVERRIDES .set(ProjectDirOverrides { config_dir, data_dir, data_local_dir, }) .ok(); } #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct Config { pub log: Option, pub tui: Option, pub modifier: Option, pub ptrace: Option, pub debugger: Option, } #[derive(Debug, Snafu)] pub enum ConfigLoadError { #[snafu(display("Config file not found."))] NotFound, #[snafu(display("Failed to load config file."))] IoError { source: io::Error }, #[snafu(display("Failed to parse config file."))] TomlError { source: toml::de::Error }, } impl Config { pub fn load(path: Option) -> Result { let config_text = match path { Some(path) => std::fs::read_to_string(path).context(IoSnafu)?, // if manually specified config doesn't exist, return a hard error None => { let Some(project_dirs) = project_directory() else { warn!("No valid home directory found! Not loading config.toml."); return Err(ConfigLoadError::NotFound); }; // ~/.config/tracexec/config.toml let config_path = project_dirs.config_dir().join("config.toml"); std::fs::read_to_string(config_path).map_err(|e| match e.kind() { io::ErrorKind::NotFound => ConfigLoadError::NotFound, _ => ConfigLoadError::IoError { source: e }, })? } }; let config: Self = toml::from_str(&config_text).context(TomlSnafu)?; Ok(config) } } #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct ModifierConfig { pub seccomp_bpf: Option, pub successful_only: Option, pub fd_in_cmdline: Option, pub stdio_in_cmdline: Option, pub resolve_proc_self_exe: Option, pub hide_cloexec_fds: Option, pub timestamp: Option, pub collect_cgroup: Option, } #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct TimestampConfig { pub enable: bool, pub inline_format: Option, } #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct PtraceConfig { pub seccomp_bpf: Option, } #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct TuiModeConfig { pub follow: Option, pub exit_handling: Option, pub active_pane: Option, pub layout: Option, #[serde(default, deserialize_with = "deserialize_frame_rate")] pub frame_rate: Option, pub max_events: Option, pub scrollback_lines: Option, #[serde(rename = "theme-file")] pub theme_file: Option, #[serde(default)] pub theme: Option, #[serde(default)] pub keys: Option, } #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct DebuggerConfig { pub default_external_command: Option, } fn is_frame_rate_invalid(v: f64) -> bool { v.is_nan() || v <= 0. || v.is_infinite() } fn deserialize_frame_rate<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let value = Option::::deserialize(deserializer)?; if let Some(value) = value.filter(|value| is_frame_rate_invalid(*value)) { return Err(serde::de::Error::invalid_value( serde::de::Unexpected::Float(value), &"a positive floating-point number", )); } Ok(value) } #[derive(Debug, Default, Clone, Deserialize, Serialize)] pub struct LogModeConfig { pub show_interpreter: Option, pub color_level: Option, pub foreground: Option, pub fd_display: Option, pub env_display: Option, pub show_comm: Option, pub show_argv: Option, pub show_filename: Option, pub show_cwd: Option, pub show_cmdline: Option, pub decode_errno: Option, } #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub enum ColorLevel { Less, #[default] Normal, More, } #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub enum FileDescriptorDisplay { Hide, Show, #[default] Diff, } #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub enum EnvDisplay { Hide, Show, #[default] Diff, } #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub enum ExitHandling { #[default] Wait, Kill, Terminate, } pub fn project_directory() -> Option { if let Some(overrides) = PROJECT_DIR_OVERRIDES.get() { return Some(TracexecProjectDirs { config_dir: overrides.config_dir.clone(), // On Linux data_dir and data_local_dir are the same for ProjectDirs. data_dir: overrides.data_dir.clone(), data_local_dir: overrides.data_local_dir.clone(), }); } ProjectDirs::from("dev", "kxxt", "tracexec").map(TracexecProjectDirs::from) } #[cfg(test)] mod tests { use std::path::PathBuf; use test_that::prelude::*; use toml; use super::*; #[test] fn test_validate_frame_rate() { // valid frame rates assert!(!is_frame_rate_invalid(5.0)); assert!(!is_frame_rate_invalid(12.5)); // too low or zero assert!(is_frame_rate_invalid(0.0)); assert!(is_frame_rate_invalid(-1.0)); // NaN or infinite assert!(is_frame_rate_invalid(f64::NAN)); assert!(is_frame_rate_invalid(f64::INFINITY)); assert!(is_frame_rate_invalid(f64::NEG_INFINITY)); } #[derive(Serialize, Deserialize)] struct FrameRate { #[serde(default, deserialize_with = "deserialize_frame_rate")] frame_rate: Option, } #[test] fn test_deserialize_frame_rate_valid() { let value: FrameRate = toml::from_str("frame_rate = 12.5").unwrap(); assert_eq!(value.frame_rate, Some(12.5)); let value: FrameRate = toml::from_str("frame_rate = 5.0").unwrap(); assert_eq!(value.frame_rate, Some(5.0)); } #[test] fn test_deserialize_frame_rate_invalid() { let value: Result = toml::from_str("frame_rate = -1"); assert_that!(value.err(), some(anything())); let value: Result = toml::from_str("frame_rate = NaN"); assert_that!(value.err(), some(anything())); let value: Result = toml::from_str("frame_rate = 0"); assert_that!(value.err(), some(anything())); } #[test] fn test_config_load_invalid_path() { let path = Some(PathBuf::from("/non/existent/config.toml")); let result = Config::load(path); assert!(matches!( result, Err(ConfigLoadError::IoError { .. }) | Err(ConfigLoadError::NotFound) )); } #[test] fn test_modifier_config_roundtrip() { let toml_str = r#" seccomp_bpf = "Auto" successful_only = true fd_in_cmdline = false stdio_in_cmdline = true resolve_proc_self_exe = true hide_cloexec_fds = false [timestamp] enable = true inline_format = "%H:%M:%S" "#; let cfg: ModifierConfig = toml::from_str(toml_str).unwrap(); assert!(cfg.successful_only.unwrap()); assert!(cfg.stdio_in_cmdline.unwrap()); assert!(cfg.timestamp.as_ref().unwrap().enable); assert_eq!( cfg .timestamp .as_ref() .unwrap() .inline_format .as_ref() .unwrap() .as_str(), "%H:%M:%S" ); } #[test] fn test_ptrace_config_roundtrip() { let toml_str = r#"seccomp_bpf = "Auto""#; let cfg: PtraceConfig = toml::from_str(toml_str).unwrap(); assert_eq!(cfg.seccomp_bpf.unwrap(), SeccompBpf::Auto); } #[test] fn test_log_mode_config_roundtrip() { let toml_str = r#" show_interpreter = true color_level = "More" foreground = false "#; let cfg: LogModeConfig = toml::from_str(toml_str).unwrap(); assert!(cfg.show_interpreter.unwrap()); assert_eq!(cfg.color_level.unwrap(), ColorLevel::More); assert!(!cfg.foreground.unwrap()); } #[test] fn test_tui_mode_config_roundtrip() { let toml_str = r#" follow = true frame_rate = 12.5 max_events = 100 theme-file = "nord.toml" theme = { app-title = { fg = "cyan", modifiers = ["bold"] } } "#; let cfg: TuiModeConfig = toml::from_str(toml_str).unwrap(); assert!(cfg.follow.unwrap()); assert_eq!(cfg.frame_rate.unwrap(), 12.5); assert_eq!(cfg.max_events.unwrap(), 100); assert_eq!(cfg.theme_file, Some(PathBuf::from("nord.toml"))); let theme = cfg.theme.unwrap(); assert_that!(theme.app_title, some(anything())); let app_title = theme.app_title.unwrap(); assert!( matches!(app_title.fg, Some(crate::cli::tui_theme::ThemeColor::Named(ref s)) if s == "cyan") ); assert_eq!(app_title.modifiers.len(), 1); } #[test] fn test_debugger_config_roundtrip() { let toml_str = r#"default_external_command = "echo hello""#; let cfg: DebuggerConfig = toml::from_str(toml_str).unwrap(); assert_eq!(cfg.default_external_command.unwrap(), "echo hello"); } }