rmcp_mux/
config.rs

1//! Configuration types and loading for rmcp_mux.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use anyhow::{Context, Result, anyhow};
9use serde::{Deserialize, Serialize};
10
11/// Sanitize a file path by canonicalizing it to prevent path traversal.
12/// Returns the canonicalized path if it exists and is valid.
13pub fn sanitize_path(path: &Path) -> Result<PathBuf> {
14    // Canonicalize resolves symlinks and .. components
15    let canonical = fs::canonicalize(path)
16        .with_context(|| format!("failed to resolve path: {}", path.display()))?;
17    Ok(canonical)
18}
19
20/// Read file contents after sanitizing the path.
21pub fn safe_read_to_string(path: &Path) -> Result<String> {
22    let safe_path = sanitize_path(path)?;
23    // Path is already canonicalized above, resolving symlinks and .. components
24    fs::read_to_string(&safe_path) // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
25        .with_context(|| format!("failed to read file: {}", safe_path.display()))
26}
27
28/// Copy file after sanitizing paths.
29pub fn safe_copy(from: &Path, to: &Path) -> Result<u64> {
30    let safe_from = sanitize_path(from)?;
31    // For destination, parent must exist but file may not yet
32    let to_parent = to
33        .parent()
34        .ok_or_else(|| anyhow!("invalid destination path"))?;
35    let _ = sanitize_path(to_parent)?;
36    // Source and dest parent validated via canonicalize
37    #[allow(clippy::let_and_return)]
38    let bytes = fs::copy(&safe_from, to)?; // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
39    Ok(bytes)
40}
41
42/// Multi-server configuration file format.
43#[derive(Debug, Clone, Deserialize, Serialize, Default)]
44pub struct Config {
45    pub servers: HashMap<String, ServerConfig>,
46}
47
48/// Per-service configuration in the config file.
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct ServerConfig {
51    pub socket: Option<String>,
52    pub cmd: Option<String>,
53    pub args: Option<Vec<String>>,
54    pub max_active_clients: Option<usize>,
55    pub tray: Option<bool>,
56    pub service_name: Option<String>,
57    pub log_level: Option<String>,
58    pub lazy_start: Option<bool>,
59    pub max_request_bytes: Option<usize>,
60    pub request_timeout_ms: Option<u64>,
61    pub restart_backoff_ms: Option<u64>,
62    pub restart_backoff_max_ms: Option<u64>,
63    pub max_restarts: Option<u64>,
64    pub status_file: Option<String>,
65}
66
67/// Resolved runtime parameters for the mux daemon.
68#[derive(Clone, Debug)]
69pub struct ResolvedParams {
70    pub socket: PathBuf,
71    pub cmd: String,
72    pub args: Vec<String>,
73    pub max_clients: usize,
74    pub tray_enabled: bool,
75    pub log_level: String,
76    pub service_name: String,
77    pub lazy_start: bool,
78    pub max_request_bytes: usize,
79    pub request_timeout: Duration,
80    pub restart_backoff: Duration,
81    pub restart_backoff_max: Duration,
82    pub max_restarts: u64,
83    pub status_file: Option<PathBuf>,
84}
85
86/// CLI options that can override config file settings.
87///
88/// This trait allows the binary to pass CLI arguments to resolve_params
89/// without the library depending on clap types.
90pub trait CliOptions {
91    fn socket(&self) -> Option<PathBuf>;
92    fn cmd(&self) -> Option<String>;
93    fn args(&self) -> Vec<String>;
94    fn max_active_clients(&self) -> usize;
95    fn lazy_start(&self) -> Option<bool>;
96    fn max_request_bytes(&self) -> Option<usize>;
97    fn request_timeout_ms(&self) -> Option<u64>;
98    fn restart_backoff_ms(&self) -> Option<u64>;
99    fn restart_backoff_max_ms(&self) -> Option<u64>;
100    fn max_restarts(&self) -> Option<u64>;
101    fn log_level(&self) -> String;
102    fn tray(&self) -> bool;
103    fn service_name(&self) -> Option<String>;
104    fn service(&self) -> Option<String>;
105    fn status_file(&self) -> Option<PathBuf>;
106}
107
108pub fn expand_path(raw: impl AsRef<str>) -> PathBuf {
109    let s = raw.as_ref();
110    if let Some(stripped) = s.strip_prefix("~/")
111        && let Some(home) = std::env::var_os("HOME")
112    {
113        return PathBuf::from(home).join(stripped);
114    }
115    PathBuf::from(s)
116}
117
118pub fn load_config(path: &Path) -> Result<Option<Config>> {
119    if !path.exists() {
120        return Ok(None);
121    }
122    let data = safe_read_to_string(path)?;
123
124    let ext = path
125        .extension()
126        .and_then(|e| e.to_str())
127        .unwrap_or("")
128        .to_ascii_lowercase();
129
130    let cfg: Config = match ext.as_str() {
131        "yaml" | "yml" => serde_yaml::from_str(&data)
132            .with_context(|| format!("failed to parse yaml config {}", path.display()))?,
133        "toml" => toml::from_str(&data)
134            .with_context(|| format!("failed to parse toml config {}", path.display()))?,
135        _ => serde_json::from_str(&data)
136            .with_context(|| format!("failed to parse json config {}", path.display()))?,
137    };
138    Ok(Some(cfg))
139}
140
141/// Resolve runtime parameters from CLI options and config file.
142///
143/// CLI options take precedence over config file settings.
144pub fn resolve_params<C: CliOptions>(cli: &C, config: Option<&Config>) -> Result<ResolvedParams> {
145    let service_cfg = if let Some(cfg) = config {
146        if let Some(name) = cli.service() {
147            let found = cfg
148                .servers
149                .get(&name)
150                .cloned()
151                .ok_or_else(|| anyhow!("service '{name}' not found in config"))?;
152            Some((name, found))
153        } else {
154            None
155        }
156    } else {
157        None
158    };
159
160    if config.is_some() && cli.service().is_none() {
161        return Err(anyhow!("--service is required when using --config"));
162    }
163
164    let socket = cli
165        .socket()
166        .or_else(|| {
167            service_cfg
168                .as_ref()
169                .and_then(|(_, c)| c.socket.clone().map(expand_path))
170        })
171        .ok_or_else(|| anyhow!("socket path not provided (use --socket or config)"))?;
172
173    let cmd = cli
174        .cmd()
175        .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.cmd.clone()))
176        .ok_or_else(|| anyhow!("cmd not provided (use --cmd or config)"))?;
177
178    let cli_args = cli.args();
179    let args = if !cli_args.is_empty() {
180        cli_args
181    } else {
182        service_cfg
183            .as_ref()
184            .and_then(|(_, c)| c.args.clone())
185            .unwrap_or_default()
186    };
187
188    let max_clients = service_cfg
189        .as_ref()
190        .and_then(|(_, c)| c.max_active_clients)
191        .unwrap_or_else(|| cli.max_active_clients());
192
193    let tray_enabled = if cli.tray() {
194        true
195    } else {
196        service_cfg
197            .as_ref()
198            .and_then(|(_, c)| c.tray)
199            .unwrap_or(false)
200    };
201
202    let log_level = service_cfg
203        .as_ref()
204        .and_then(|(_, c)| c.log_level.clone())
205        .unwrap_or_else(|| cli.log_level());
206
207    let lazy_start = cli.lazy_start().unwrap_or_else(|| {
208        service_cfg
209            .as_ref()
210            .and_then(|(_, c)| c.lazy_start)
211            .unwrap_or(false)
212    });
213
214    let max_request_bytes = cli.max_request_bytes().unwrap_or_else(|| {
215        service_cfg
216            .as_ref()
217            .and_then(|(_, c)| c.max_request_bytes)
218            .unwrap_or(1_048_576)
219    });
220
221    let request_timeout = Duration::from_millis(cli.request_timeout_ms().unwrap_or_else(|| {
222        service_cfg
223            .as_ref()
224            .and_then(|(_, c)| c.request_timeout_ms)
225            .unwrap_or(30_000)
226    }));
227
228    let restart_backoff = Duration::from_millis(
229        cli.restart_backoff_ms()
230            .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.restart_backoff_ms))
231            .unwrap_or(1_000),
232    );
233    let restart_backoff_max = Duration::from_millis(
234        cli.restart_backoff_max_ms()
235            .or_else(|| {
236                service_cfg
237                    .as_ref()
238                    .and_then(|(_, c)| c.restart_backoff_max_ms)
239            })
240            .unwrap_or(30_000),
241    );
242    let max_restarts = cli
243        .max_restarts()
244        .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.max_restarts))
245        .unwrap_or(5);
246
247    let status_file = cli
248        .status_file()
249        .map(|p| p.to_str().map(expand_path).unwrap_or_else(|| p.clone()))
250        .or_else(|| {
251            service_cfg
252                .as_ref()
253                .and_then(|(_, c)| c.status_file.as_deref().map(expand_path))
254        });
255
256    let service_name_raw = cli
257        .service_name()
258        .or_else(|| {
259            service_cfg
260                .as_ref()
261                .and_then(|(_, c)| c.service_name.clone())
262        })
263        .or_else(|| {
264            socket
265                .file_name()
266                .and_then(|n| n.to_string_lossy().split('.').next().map(|s| s.to_string()))
267        })
268        .unwrap_or_else(|| "rmcp_mux".to_string());
269
270    Ok(ResolvedParams {
271        socket,
272        cmd,
273        args,
274        max_clients,
275        tray_enabled,
276        log_level,
277        service_name: service_name_raw,
278        lazy_start,
279        max_request_bytes,
280        request_timeout,
281        restart_backoff,
282        restart_backoff_max,
283        max_restarts,
284        status_file,
285    })
286}