par_term_config/config/persistence.rs
1//! Config persistence, path resolution, and session-state methods for `Config`.
2//!
3//! Covers:
4//! - `load` / `save` (YAML file I/O with atomic write)
5//! - XDG-compliant path helpers (`config_path`, `config_dir`, `state_file_path`, etc.)
6//! - Session-state persistence (`save_last_working_directory`, `load_last_working_directory`)
7//! - Startup-directory resolution (`get_effective_startup_directory`)
8//! - Miscellaneous runtime helpers (`resolve_tmux_path`, `logs_dir`, `with_title`,
9//! `get_pane_background`, `should_prompt_shell_integration`, `should_prompt_integrations`)
10
11use super::config_struct::Config;
12use crate::types::{BackgroundImageMode, InstallPromptState, StartupDirectoryMode};
13use anyhow::Result;
14use serde::{Deserialize, Serialize};
15use std::fs;
16use std::path::PathBuf;
17
18impl Config {
19 /// Load configuration from file or create default
20 ///
21 /// When no config file exists, a default one is written to disk and
22 /// returned.
23 ///
24 /// # Errors
25 ///
26 /// Returns an error if the config file resolves outside the config
27 /// directory (a redirected symlink), if it cannot be read, if it is not
28 /// valid YAML for the [`Config`] schema, or — on the first-run path — if
29 /// writing the default config fails.
30 pub fn load() -> Result<Self> {
31 let config_path = Self::config_path();
32 log::info!("Config path: {:?}", config_path);
33
34 if config_path.exists() {
35 // Validate that the config file has not been redirected (e.g. via a
36 // symlink) to a location outside the expected config directory.
37 let config_dir = Self::config_dir();
38 if let Err(e) = Self::validate_config_path(&config_path, &config_dir) {
39 log::error!("Config path validation failed: {e}");
40 return Err(e.into());
41 }
42
43 log::info!("Loading existing config from {:?}", config_path);
44
45 // Security: warn if the config file is readable by group or others.
46 // The config file may contain sensitive values (API keys, SSH paths,
47 // trigger commands) that should not be exposed to other users on a
48 // shared system.
49 #[cfg(unix)]
50 {
51 use std::os::unix::fs::PermissionsExt;
52 if let Ok(metadata) = fs::metadata(&config_path) {
53 let mode = metadata.permissions().mode();
54 // Check group-readable (0o040) or world-readable (0o004) bits.
55 if mode & 0o044 != 0 {
56 log::warn!(
57 "Config file {:?} has insecure permissions (mode {:04o}). \
58 It is readable by group or others, which may expose sensitive \
59 configuration values. Run: chmod 600 {:?}",
60 config_path,
61 mode & 0o777,
62 config_path,
63 );
64 }
65 }
66 }
67
68 let contents = fs::read_to_string(&config_path)?;
69
70 // Pre-scan the raw YAML for `allow_all_env_vars: true` before
71 // variable substitution, since the config isn't parsed yet.
72 let allow_all = super::env_vars::pre_scan_allow_all_env_vars(&contents);
73
74 // SEC-005: Emit a startup warning when allow_all_env_vars: true is detected.
75 // This setting allows any environment variable (including secrets) to be
76 // substituted into config values, which can expose sensitive data if a
77 // shared or imported config file uses ${SECRET_VAR} references.
78 if allow_all {
79 eprintln!(
80 "[par-term SECURITY WARNING] Config option `allow_all_env_vars: true` is set.\n\
81 This allows ALL environment variables to be interpolated into config values,\n\
82 including sensitive variables such as API keys, tokens, and passwords.\n\
83 A shared or imported config with ${{SENSITIVE_VAR}} references could expose\n\
84 your secrets. Only use this setting in a non-shared, local-only config.\n\
85 Recommendation: use a CLAUDE.local.md-style local override, or remove\n\
86 `allow_all_env_vars: true` and add needed variables to the allowlist instead."
87 );
88 }
89
90 let contents =
91 super::env_vars::substitute_variables_with_allowlist(&contents, allow_all);
92 let mut config: Config = serde_yaml_ng::from_str(&contents)?;
93
94 // Migrate legacy values that may be stored in user configs.
95 config.migrate_legacy_values();
96
97 // Warn about triggers with prompt_before_run: false, since the
98 // denylist is the only protection in that mode and it is bypassable.
99 config.warn_insecure_triggers();
100
101 // Merge in any new default keybindings that don't exist in user's config
102 config.merge_default_keybindings();
103
104 // Merge in any new default status bar widgets that don't exist in user's config
105 config.merge_default_widgets();
106
107 // Generate keybindings for snippets and actions
108 config.generate_snippet_action_keybindings();
109
110 // Load last working directory from state file (for "previous session" mode)
111 config.load_last_working_directory();
112
113 Ok(config)
114 } else {
115 log::info!(
116 "Config file not found, creating default at {:?}",
117 config_path
118 );
119 // Create default config and save it
120 let mut config = Self::default();
121 // Generate keybindings for snippets and actions
122 config.generate_snippet_action_keybindings();
123 if let Err(e) = config.save() {
124 log::error!("Failed to save default config: {}", e);
125 return Err(e);
126 }
127
128 // Load last working directory from state file (for "previous session" mode)
129 config.load_last_working_directory();
130
131 log::info!("Default config created successfully");
132 Ok(config)
133 }
134 }
135
136 /// Save configuration to file
137 ///
138 /// SEC-008/SEC-021: written through [`crate::atomic_save`], which stages the
139 /// write in a sibling `0600` temp file, **fsyncs it**, renames it over the
140 /// real config and then fsyncs the directory. This used to be an inline
141 /// temp-and-rename with no fsync, which still lost the file on a power cut:
142 /// the rename can reach disk before the data it points at.
143 ///
144 /// # Errors
145 ///
146 /// Returns an error if the config directory cannot be created, if the
147 /// config cannot be serialized to YAML, or if writing, syncing or renaming
148 /// the temp file fails.
149 pub fn save(&self) -> Result<()> {
150 crate::atomic_save::save_yaml_atomic(&Self::config_path(), self)
151 }
152
153 /// Get the configuration file path.
154 ///
155 /// Always [`Config::config_dir`] plus `config.yaml`. These two used to resolve
156 /// the directory independently, which meant any change to one silently
157 /// desynchronised the other.
158 pub fn config_path() -> PathBuf {
159 Self::config_dir().join("config.yaml")
160 }
161
162 /// Get the configuration directory path.
163 ///
164 /// On Unix this honours `XDG_CONFIG_HOME`, falling back to `~/.config/par-term`.
165 /// On Windows it is `%APPDATA%\par-term`; the XDG variables are a
166 /// freedesktop convention and are deliberately not consulted there.
167 ///
168 /// Only `XDG_CONFIG_HOME` is read. `XDG_DATA_HOME`, `XDG_STATE_HOME`,
169 /// `XDG_CACHE_HOME` and `XDG_RUNTIME_DIR` are **not** honoured — par-term keeps
170 /// all of its state under the config directory. See ENH-008.
171 pub fn config_dir() -> PathBuf {
172 #[cfg(target_os = "windows")]
173 {
174 dirs::config_dir()
175 .map(|dir| dir.join("par-term"))
176 .unwrap_or_else(|| PathBuf::from("."))
177 }
178 #[cfg(not(target_os = "windows"))]
179 {
180 unix_config_dir(std::env::var_os("XDG_CONFIG_HOME"), dirs::home_dir())
181 }
182 }
183
184 /// Get the shell integration directory (same as config dir)
185 pub fn shell_integration_dir() -> PathBuf {
186 Self::config_dir()
187 }
188
189 /// Resolve the session logs directory path, expanding a leading `~/`.
190 ///
191 /// QA-029: this is a pure path resolver and does **not** create the
192 /// directory. It used to `create_dir_all` as a side effect, which meant the
193 /// settings window created a directory just by rendering — including a
194 /// directory per keystroke while the user typed a new path into the
195 /// "Log directory" field, and at the process umask rather than the `0o700`
196 /// that SEC-010 requires of a directory holding session logs.
197 ///
198 /// The two callers that actually write logs (`Tab::toggle_session_logging`
199 /// and the auto-log path in `Tab::new`) already create the directory
200 /// themselves and then chmod it to `0o700`, so nothing is lost by removing
201 /// the side effect here.
202 pub fn logs_dir(&self) -> PathBuf {
203 match self.session_log.session_log_directory.strip_prefix("~/") {
204 Some(rest) => match dirs::home_dir() {
205 Some(home) => home.join(rest),
206 None => PathBuf::from(&self.session_log.session_log_directory),
207 },
208 None => PathBuf::from(&self.session_log.session_log_directory),
209 }
210 }
211
212 /// Resolve the tmux executable path at runtime.
213 /// If the configured path is absolute and exists, use it.
214 /// If it's "tmux" (the default), search PATH and common installation locations.
215 /// This handles cases where PATH may be incomplete (e.g., app launched from Finder).
216 pub fn resolve_tmux_path(&self) -> String {
217 let configured = &self.tmux.tmux_path;
218
219 // If it's an absolute path and exists, use it directly
220 if configured.starts_with('/') && std::path::Path::new(configured).exists() {
221 return configured.clone();
222 }
223
224 // If it's not just "tmux", return it and let the OS try
225 if configured != "tmux" {
226 return configured.clone();
227 }
228
229 // Search for tmux in PATH
230 if let Ok(path_env) = std::env::var("PATH") {
231 let separator = if cfg!(windows) { ';' } else { ':' };
232 let executable = if cfg!(windows) { "tmux.exe" } else { "tmux" };
233
234 for dir in path_env.split(separator) {
235 let candidate = std::path::Path::new(dir).join(executable);
236 if candidate.exists() {
237 return candidate.to_string_lossy().to_string();
238 }
239 }
240 }
241
242 // Fall back to common paths for environments where PATH might be incomplete
243 #[cfg(target_os = "macos")]
244 {
245 let macos_paths = [
246 "/opt/homebrew/bin/tmux", // Homebrew on Apple Silicon
247 "/usr/local/bin/tmux", // Homebrew on Intel / MacPorts
248 ];
249 for path in macos_paths {
250 if std::path::Path::new(path).exists() {
251 return path.to_string();
252 }
253 }
254 }
255
256 #[cfg(target_os = "linux")]
257 {
258 let linux_paths = [
259 "/usr/bin/tmux", // Most distros
260 "/usr/local/bin/tmux", // Manual install
261 "/snap/bin/tmux", // Snap package
262 ];
263 for path in linux_paths {
264 if std::path::Path::new(path).exists() {
265 return path.to_string();
266 }
267 }
268 }
269
270 // Final fallback - return configured value
271 configured.clone()
272 }
273
274 /// Set the window title
275 pub fn with_title(mut self, title: impl Into<String>) -> Self {
276 self.window_title = title.into();
277 self
278 }
279
280 /// Check if shell integration should be prompted
281 ///
282 /// # Arguments
283 /// * `current_version` - The application version (from root crate's `VERSION` constant)
284 pub fn should_prompt_shell_integration(&self, current_version: &str) -> bool {
285 if self.integrations.shell_integration_state != InstallPromptState::Ask {
286 return false;
287 }
288
289 // Check if already prompted for this version
290 if let Some(ref prompted) = self
291 .integrations
292 .integration_versions
293 .shell_integration_prompted_version
294 && prompted == current_version
295 {
296 return false;
297 }
298
299 // Check if installed and up to date
300 if let Some(ref installed) = self
301 .integrations
302 .integration_versions
303 .shell_integration_installed_version
304 && installed == current_version
305 {
306 return false;
307 }
308
309 true
310 }
311
312 /// Check if either integration should be prompted
313 ///
314 /// # Arguments
315 /// * `current_version` - The application version (from root crate's `VERSION` constant)
316 pub fn should_prompt_integrations(&self, current_version: &str) -> bool {
317 self.should_prompt_shader_install_versioned(current_version)
318 || self.should_prompt_shell_integration(current_version)
319 }
320
321 /// Get the effective startup directory based on configuration mode.
322 ///
323 /// Priority:
324 /// 1. Legacy `working_directory` if set (backward compatibility)
325 /// 2. Based on `startup_directory_mode`:
326 /// - Home: Returns user's home directory
327 /// - Previous: Returns `last_working_directory` if valid, else home
328 /// - Custom: Returns `startup_directory` if set and valid, else home
329 ///
330 /// Returns None if the effective directory doesn't exist (caller should fall back to default).
331 pub fn get_effective_startup_directory(&self) -> Option<String> {
332 // Legacy working_directory takes precedence for backward compatibility
333 if let Some(ref wd) = self.shell.working_directory {
334 let expanded = Self::expand_home_dir(wd);
335 if std::path::Path::new(&expanded).exists() {
336 return Some(expanded);
337 }
338 log::warn!(
339 "Configured working_directory '{}' does not exist, using default",
340 wd
341 );
342 }
343
344 match self.shell.startup_directory_mode {
345 StartupDirectoryMode::Home => {
346 // Return home directory
347 dirs::home_dir().map(|p| p.to_string_lossy().to_string())
348 }
349 StartupDirectoryMode::Previous => {
350 // Return last working directory if it exists
351 if let Some(ref last_dir) = self.shell.last_working_directory {
352 let expanded = Self::expand_home_dir(last_dir);
353 if std::path::Path::new(&expanded).exists() {
354 return Some(expanded);
355 }
356 log::warn!(
357 "Previous session directory '{}' no longer exists, using home",
358 last_dir
359 );
360 }
361 // Fall back to home
362 dirs::home_dir().map(|p| p.to_string_lossy().to_string())
363 }
364 StartupDirectoryMode::Custom => {
365 // Return custom directory if set and exists
366 if let Some(ref custom_dir) = self.shell.startup_directory {
367 let expanded = Self::expand_home_dir(custom_dir);
368 if std::path::Path::new(&expanded).exists() {
369 return Some(expanded);
370 }
371 log::warn!(
372 "Custom startup directory '{}' does not exist, using home",
373 custom_dir
374 );
375 }
376 // Fall back to home
377 dirs::home_dir().map(|p| p.to_string_lossy().to_string())
378 }
379 }
380 }
381
382 /// Expand ~ to home directory in a path string
383 fn expand_home_dir(path: &str) -> String {
384 if let Some(suffix) = path.strip_prefix("~/")
385 && let Some(home) = dirs::home_dir()
386 {
387 return home.join(suffix).to_string_lossy().to_string();
388 }
389 path.to_string()
390 }
391
392 /// Get the state file path for storing session state (like last working directory)
393 pub fn state_file_path() -> PathBuf {
394 #[cfg(target_os = "windows")]
395 {
396 if let Some(data_dir) = dirs::data_local_dir() {
397 data_dir.join("par-term").join("state.yaml")
398 } else {
399 PathBuf::from("state.yaml")
400 }
401 }
402 #[cfg(not(target_os = "windows"))]
403 {
404 if let Some(home_dir) = dirs::home_dir() {
405 home_dir
406 .join(".local")
407 .join("share")
408 .join("par-term")
409 .join("state.yaml")
410 } else {
411 PathBuf::from("state.yaml")
412 }
413 }
414 }
415
416 /// Save the last working directory to state file
417 ///
418 /// Updates `self.last_working_directory` and persists it to `state.yaml`,
419 /// so the value survives across sessions.
420 ///
421 /// SEC-021: written through [`crate::atomic_save`] at mode `0o600`. The
422 /// previous inline temp-and-rename had neither an fsync nor a mode, so the
423 /// path the user was last working in was world-readable.
424 ///
425 /// # Errors
426 ///
427 /// Returns an error if the state directory cannot be created, if the state
428 /// cannot be serialized to YAML, or if writing, syncing or renaming the
429 /// temp file fails. The in-memory field is updated before any of these can
430 /// fail.
431 pub fn save_last_working_directory(&mut self, directory: &str) -> Result<()> {
432 self.shell.last_working_directory = Some(directory.to_string());
433
434 let state_path = Self::state_file_path();
435
436 // Create a minimal state struct for persistence
437 #[derive(Serialize)]
438 struct SessionState {
439 last_working_directory: Option<String>,
440 }
441
442 let state = SessionState {
443 last_working_directory: Some(directory.to_string()),
444 };
445
446 crate::atomic_save::save_yaml_atomic(&state_path, &state)?;
447
448 log::debug!(
449 "Saved last working directory to {:?}: {}",
450 state_path,
451 directory
452 );
453 Ok(())
454 }
455
456 /// Load the last working directory from state file
457 pub fn load_last_working_directory(&mut self) {
458 let state_path = Self::state_file_path();
459 if !state_path.exists() {
460 return;
461 }
462
463 #[derive(Deserialize)]
464 struct SessionState {
465 last_working_directory: Option<String>,
466 }
467
468 match fs::read_to_string(&state_path) {
469 Ok(contents) => {
470 if let Ok(state) = serde_yaml_ng::from_str::<SessionState>(&contents)
471 && let Some(dir) = state.last_working_directory
472 {
473 log::debug!("Loaded last working directory from state file: {}", dir);
474 self.shell.last_working_directory = Some(dir);
475 }
476 }
477 Err(e) => {
478 log::warn!("Failed to read state file {:?}: {}", state_path, e);
479 }
480 }
481 }
482
483 /// Get per-pane background config for a given pane index, if configured
484 /// Returns (image_path, mode, opacity, darken) tuple for easy conversion to runtime type
485 pub fn get_pane_background(
486 &self,
487 index: usize,
488 ) -> Option<(String, BackgroundImageMode, f32, f32)> {
489 self.image
490 .pane_backgrounds
491 .iter()
492 .find(|pb| pb.index == index)
493 .map(|pb| (pb.image.clone(), pb.mode, pb.opacity, pb.darken))
494 }
495
496 /// Migrate legacy config values that may be stored in older user config files.
497 ///
498 /// - `minimum_contrast == 1.0` was the old default; map it to 0.0 (disabled).
499 /// - `minimum_contrast` is clamped to 0.99 max so 1.0 is never an active value.
500 pub(crate) fn migrate_legacy_values(&mut self) {
501 if (self.font_rendering.minimum_contrast - 1.0_f32).abs() < f32::EPSILON {
502 log::info!("minimum_contrast was 1.0 (legacy default), resetting to 0.0 (disabled)");
503 self.font_rendering.minimum_contrast = 0.0;
504 } else {
505 self.font_rendering.minimum_contrast = self.font_rendering.minimum_contrast.min(0.99);
506 }
507 }
508
509 /// Collect and emit security warnings for any triggers configured with
510 /// `prompt_before_run: false` that also contain dangerous actions
511 /// (`RunCommand`, `SendText`, or `SplitPane`).
512 ///
513 /// Called during config load so that users are immediately informed when
514 /// their configuration reduces the security posture. In addition to
515 /// writing a prominent warning to stderr, the insecure trigger names are
516 /// stored in [`Config::insecure_trigger_names`] so the UI layer can
517 /// render a persistent visual warning banner.
518 ///
519 /// Triggers with `prompt_before_run: false` that do not also set
520 /// `i_accept_the_risk: true` are recorded in
521 /// [`Config::unaccepted_risk_trigger_names`] and will be blocked at
522 /// execution time.
523 pub(crate) fn warn_insecure_triggers(&mut self) {
524 self.insecure_trigger_names.clear();
525 self.unaccepted_risk_trigger_names.clear();
526 for trigger in &self.automation.triggers {
527 if !trigger.prompt_before_run && trigger.actions.iter().any(|a| a.is_dangerous()) {
528 crate::automation::warn_prompt_before_run_false(
529 &trigger.name,
530 trigger.i_accept_the_risk,
531 );
532 self.insecure_trigger_names.push(trigger.name.clone());
533 if !trigger.i_accept_the_risk {
534 self.unaccepted_risk_trigger_names
535 .push(trigger.name.clone());
536 }
537 }
538 }
539 }
540}
541
542/// Resolve the Unix configuration directory from the XDG variable and home dir.
543///
544/// Split out of [`Config::config_dir`] so it can be tested without touching the
545/// process environment: `std::env::set_var` is not thread-safe — glibc's `setenv`
546/// can reallocate and free `environ` under a concurrent `getenv` — so tests pass
547/// the values in instead.
548#[cfg(not(target_os = "windows"))]
549fn unix_config_dir(xdg_config_home: Option<std::ffi::OsString>, home: Option<PathBuf>) -> PathBuf {
550 // The XDG spec requires these variables to hold an absolute path and says an
551 // implementation "should consider the path invalid and ignore it" otherwise,
552 // which also covers the empty-string case.
553 if let Some(xdg) = xdg_config_home {
554 let path = PathBuf::from(xdg);
555 if path.is_absolute() {
556 return path.join("par-term");
557 }
558 }
559 match home {
560 Some(home) => home.join(".config").join("par-term"),
561 None => PathBuf::from("."),
562 }
563}
564
565#[cfg(all(test, not(target_os = "windows")))]
566mod xdg_tests {
567 use super::unix_config_dir;
568 use std::ffi::OsString;
569 use std::path::PathBuf;
570
571 fn home() -> Option<PathBuf> {
572 Some(PathBuf::from("/home/u"))
573 }
574
575 #[test]
576 fn absolute_xdg_config_home_wins() {
577 assert_eq!(
578 unix_config_dir(Some(OsString::from("/dotfiles/cfg")), home()),
579 PathBuf::from("/dotfiles/cfg/par-term")
580 );
581 }
582
583 #[test]
584 fn unset_falls_back_to_dot_config() {
585 assert_eq!(
586 unix_config_dir(None, home()),
587 PathBuf::from("/home/u/.config/par-term")
588 );
589 }
590
591 /// The spec requires an absolute path; relative and empty values are invalid
592 /// and must not be honoured, or a stray `XDG_CONFIG_HOME=.` would scatter
593 /// config into whatever directory par-term happened to start in.
594 #[test]
595 fn relative_or_empty_xdg_is_ignored() {
596 for bad in ["", "relative/path", "."] {
597 assert_eq!(
598 unix_config_dir(Some(OsString::from(bad)), home()),
599 PathBuf::from("/home/u/.config/par-term"),
600 "expected {bad:?} to be rejected as non-absolute"
601 );
602 }
603 }
604
605 #[test]
606 fn no_home_and_no_xdg_falls_back_to_cwd() {
607 assert_eq!(unix_config_dir(None, None), PathBuf::from("."));
608 }
609}