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