Skip to main content

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}