Skip to main content

purple_ssh/
snippet.rs

1use std::collections::HashMap;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::{Command, ExitStatus, Stdio};
5
6use crate::fs_util;
7
8/// A saved command snippet.
9#[derive(Debug, Clone, PartialEq)]
10pub struct Snippet {
11    pub name: String,
12    pub command: String,
13    pub description: String,
14}
15
16/// Result of running a snippet on a host.
17pub struct SnippetResult {
18    pub status: ExitStatus,
19    pub stdout: String,
20    pub stderr: String,
21}
22
23/// Snippet storage backed by ~/.purple/snippets (INI-style).
24#[derive(Debug, Clone, Default)]
25pub struct SnippetStore {
26    pub snippets: Vec<Snippet>,
27    /// Override path for save(). None uses the default ~/.purple/snippets.
28    pub path_override: Option<PathBuf>,
29    /// Default target host aliases per snippet name, persisted as the optional
30    /// `hosts=` line. Pre-selected when the run picker opens. Kept out of the
31    /// `Snippet` struct so existing call sites that build snippets stay
32    /// untouched; the association is by name.
33    pub targets: HashMap<String, Vec<String>>,
34}
35
36fn config_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
37    paths.map(crate::runtime::env::Paths::snippets_file)
38}
39
40impl SnippetStore {
41    /// Load snippets from `~/.purple/snippets`, resolved from the injected
42    /// `paths`. The resolved path is stored as `path_override` so a later
43    /// `save()` writes back to the same location without re-resolving.
44    /// Returns an empty store when the file does not exist (normal
45    /// first-use) or when no home directory is known.
46    pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
47        let path = match config_path(paths) {
48            Some(p) => p,
49            None => return Self::default(),
50        };
51        let content = match std::fs::read_to_string(&path) {
52            Ok(c) => c,
53            Err(e) if e.kind() == io::ErrorKind::NotFound => {
54                return Self {
55                    path_override: Some(path),
56                    ..Self::default()
57                };
58            }
59            Err(e) => {
60                log::warn!("[config] Could not read {}: {}", path.display(), e);
61                return Self {
62                    path_override: Some(path),
63                    ..Self::default()
64                };
65            }
66        };
67        Self {
68            path_override: Some(path),
69            ..Self::parse(&content)
70        }
71    }
72
73    /// Parse INI-style snippet config.
74    pub fn parse(content: &str) -> Self {
75        let mut snippets = Vec::new();
76        let mut targets: HashMap<String, Vec<String>> = HashMap::new();
77        let mut current: Option<Snippet> = None;
78
79        for line in content.lines() {
80            let trimmed = line.trim();
81            if trimmed.is_empty() || trimmed.starts_with('#') {
82                continue;
83            }
84            if trimmed.starts_with('[') && trimmed.ends_with(']') {
85                if let Some(snippet) = current.take()
86                    && !snippet.command.is_empty()
87                    && !snippets.iter().any(|s: &Snippet| s.name == snippet.name)
88                {
89                    snippets.push(snippet);
90                }
91                let name = trimmed[1..trimmed.len() - 1].trim().to_string();
92                if snippets.iter().any(|s| s.name == name) {
93                    current = None;
94                    continue;
95                }
96                current = Some(Snippet {
97                    name,
98                    command: String::new(),
99                    description: String::new(),
100                });
101            } else if let Some(ref mut snippet) = current
102                && let Some((key, value)) = trimmed.split_once('=')
103            {
104                let key = key.trim();
105                // Trim whitespace around key but preserve value content
106                // (only trim leading whitespace after '=', not trailing)
107                let value = value.trim_start().to_string();
108                match key {
109                    "command" => snippet.command = value,
110                    "description" => snippet.description = value,
111                    "hosts" => {
112                        let aliases: Vec<String> = value
113                            .split(',')
114                            .map(|a| unescape_alias(a.trim()))
115                            .filter(|a| !a.is_empty())
116                            .collect();
117                        if !aliases.is_empty() {
118                            targets.insert(snippet.name.clone(), aliases);
119                        }
120                    }
121                    _ => {}
122                }
123            }
124        }
125        if let Some(snippet) = current
126            && !snippet.command.is_empty()
127            && !snippets.iter().any(|s| s.name == snippet.name)
128        {
129            snippets.push(snippet);
130        }
131        // Drop targets for sections that did not yield a snippet (no command,
132        // or a duplicate name) so the map never holds orphans.
133        targets.retain(|name, _| snippets.iter().any(|s| &s.name == name));
134        Self {
135            snippets,
136            path_override: None,
137            targets,
138        }
139    }
140
141    /// Save snippets to ~/.purple/snippets (atomic write, chmod 600).
142    pub fn save(&self) -> io::Result<()> {
143        if crate::demo_flag::is_demo() {
144            return Ok(());
145        }
146        let Some(path) = self.path_override.clone() else {
147            return Err(io::Error::new(
148                io::ErrorKind::NotFound,
149                "Could not determine home directory",
150            ));
151        };
152
153        let mut content = String::new();
154        for (i, snippet) in self.snippets.iter().enumerate() {
155            if i > 0 {
156                content.push('\n');
157            }
158            content.push_str(&format!("[{}]\n", snippet.name));
159            content.push_str(&format!("command={}\n", snippet.command));
160            if !snippet.description.is_empty() {
161                content.push_str(&format!("description={}\n", snippet.description));
162            }
163            if let Some(hosts) = self.targets.get(&snippet.name)
164                && !hosts.is_empty()
165            {
166                let joined = hosts
167                    .iter()
168                    .map(|a| escape_alias(a))
169                    .collect::<Vec<_>>()
170                    .join(",");
171                content.push_str(&format!("hosts={joined}\n"));
172            }
173        }
174
175        fs_util::atomic_write(&path, content.as_bytes())
176    }
177
178    /// Default target host aliases saved for `name` (empty when none).
179    pub fn targets_for(&self, name: &str) -> &[String] {
180        self.targets.get(name).map(|v| v.as_slice()).unwrap_or(&[])
181    }
182
183    /// Set (or clear, when empty) the default target host aliases for `name`.
184    pub fn set_targets(&mut self, name: &str, aliases: Vec<String>) {
185        if aliases.is_empty() {
186            self.targets.remove(name);
187        } else {
188            self.targets.insert(name.to_string(), aliases);
189        }
190    }
191
192    /// Get a snippet by name.
193    pub fn get(&self, name: &str) -> Option<&Snippet> {
194        self.snippets.iter().find(|s| s.name == name)
195    }
196
197    /// Add or replace a snippet.
198    pub fn set(&mut self, snippet: Snippet) {
199        if let Some(existing) = self.snippets.iter_mut().find(|s| s.name == snippet.name) {
200            *existing = snippet;
201        } else {
202            self.snippets.push(snippet);
203        }
204    }
205
206    /// Remove a snippet by name, dropping its saved targets too.
207    pub fn remove(&mut self, name: &str) {
208        self.snippets.retain(|s| s.name != name);
209        self.targets.remove(name);
210    }
211}
212
213/// Percent-encode the comma (and the percent sign itself) in a host alias so it
214/// survives the comma-joined `hosts=` line. A host whose SSH `Host` line uses a
215/// comma (e.g. `Host web1,web2`) is a single concrete alias containing a comma;
216/// without escaping it would shred into phantom aliases on reload.
217fn escape_alias(alias: &str) -> String {
218    alias.replace('%', "%25").replace(',', "%2C")
219}
220
221/// Inverse of [`escape_alias`]. Unescape order is the reverse of escape order so
222/// a literal `%2C` in an alias round-trips intact.
223fn unescape_alias(token: &str) -> String {
224    token.replace("%2C", ",").replace("%25", "%")
225}
226
227/// Validate a snippet name: non-empty, no leading/trailing whitespace,
228/// no `#`, no `[`, no `]`, no control characters.
229pub fn validate_name(name: &str) -> Result<(), String> {
230    if name.trim().is_empty() {
231        return Err(crate::messages::SNIPPET_NAME_EMPTY.to_string());
232    }
233    if name != name.trim() {
234        return Err(crate::messages::SNIPPET_NAME_WHITESPACE.to_string());
235    }
236    if name.contains('#') || name.contains('[') || name.contains(']') {
237        return Err(crate::messages::SNIPPET_NAME_INVALID_CHARS.to_string());
238    }
239    if name.contains(|c: char| c.is_control()) {
240        return Err(crate::messages::SNIPPET_NAME_CONTROL_CHARS.to_string());
241    }
242    Ok(())
243}
244
245/// Validate a snippet command: non-empty, no control characters (except tab).
246pub fn validate_command(command: &str) -> Result<(), String> {
247    if command.trim().is_empty() {
248        return Err(crate::messages::SNIPPET_COMMAND_EMPTY.to_string());
249    }
250    if command.contains(|c: char| c.is_control() && c != '\t') {
251        return Err(crate::messages::SNIPPET_COMMAND_CONTROL_CHARS.to_string());
252    }
253    Ok(())
254}
255
256// =========================================================================
257// Parameter support
258// =========================================================================
259
260/// A parameter found in a snippet command template.
261#[derive(Debug, Clone, PartialEq)]
262pub struct SnippetParam {
263    pub name: String,
264    pub default: Option<String>,
265}
266
267/// Shell-escape a string with single quotes (POSIX).
268/// Internal single quotes are escaped as `'\''`.
269pub fn shell_escape(s: &str) -> String {
270    format!("'{}'", s.replace('\'', "'\\''"))
271}
272
273/// Parse `{{name}}` and `{{name:default}}` from a command string.
274/// Returns params in order of first appearance, deduplicated. Max 20 params.
275pub fn parse_params(command: &str) -> Vec<SnippetParam> {
276    let mut params = Vec::new();
277    let mut seen = std::collections::HashSet::new();
278    let bytes = command.as_bytes();
279    let len = bytes.len();
280    let mut i = 0;
281    while i + 3 < len {
282        if bytes[i] == b'{'
283            && bytes.get(i + 1) == Some(&b'{')
284            && let Some(end) = command[i + 2..].find("}}")
285        {
286            let inner = &command[i + 2..i + 2 + end];
287            let (name, default) = if let Some((n, d)) = inner.split_once(':') {
288                (n.to_string(), Some(d.to_string()))
289            } else {
290                (inner.to_string(), None)
291            };
292            if validate_param_name(&name).is_ok() && !seen.contains(&name) && params.len() < 20 {
293                seen.insert(name.clone());
294                params.push(SnippetParam { name, default });
295            }
296            i = i + 2 + end + 2;
297            continue;
298        }
299        i += 1;
300    }
301    params
302}
303
304/// Count the distinct valid `{{name}}` parameters in a command without
305/// allocating the full parsed list. Equal to `parse_params(command).len()`;
306/// used by the list row, which only needs the count, on every frame.
307pub fn count_params(command: &str) -> usize {
308    let mut seen: Vec<&str> = Vec::new();
309    let bytes = command.as_bytes();
310    let len = bytes.len();
311    let mut i = 0;
312    while i + 3 < len {
313        if bytes[i] == b'{'
314            && bytes.get(i + 1) == Some(&b'{')
315            && let Some(end) = command[i + 2..].find("}}")
316        {
317            let inner = &command[i + 2..i + 2 + end];
318            let name = inner.split_once(':').map_or(inner, |(n, _)| n);
319            if validate_param_name(name).is_ok() && !seen.contains(&name) && seen.len() < 20 {
320                seen.push(name);
321            }
322            i = i + 2 + end + 2;
323            continue;
324        }
325        i += 1;
326    }
327    seen.len()
328}
329
330/// Validate a parameter name: non-empty, alphanumeric/underscore/hyphen only.
331/// Rejects `{`, `}`, `'`, whitespace and control chars.
332pub fn validate_param_name(name: &str) -> Result<(), String> {
333    if name.is_empty() {
334        return Err(crate::messages::SNIPPET_PARAM_NAME_EMPTY.to_string());
335    }
336    if !name
337        .chars()
338        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
339    {
340        return Err(crate::messages::snippet_param_name_invalid(name));
341    }
342    Ok(())
343}
344
345/// Substitute parameters into a command template (single-pass).
346/// All values (user-provided and defaults) are shell-escaped.
347pub fn substitute_params(
348    command: &str,
349    values: &std::collections::HashMap<String, String>,
350) -> String {
351    let mut result = String::with_capacity(command.len());
352    let bytes = command.as_bytes();
353    let len = bytes.len();
354    let mut i = 0;
355    while i < len {
356        if i + 3 < len
357            && bytes[i] == b'{'
358            && bytes[i + 1] == b'{'
359            && let Some(end) = command[i + 2..].find("}}")
360        {
361            let inner = &command[i + 2..i + 2 + end];
362            let (name, default) = if let Some((n, d)) = inner.split_once(':') {
363                (n, Some(d))
364            } else {
365                (inner, None)
366            };
367            // Only a valid name is a parameter. parse_params and count_params
368            // gate the same way, so an invalid placeholder (e.g. `{{a b}}`)
369            // stays literal here instead of being silently rewritten to ''.
370            if validate_param_name(name).is_ok() {
371                let value = values
372                    .get(name)
373                    .filter(|v| !v.is_empty())
374                    .map(|v| v.as_str())
375                    .or(default)
376                    .unwrap_or("");
377                result.push_str(&shell_escape(value));
378                i = i + 2 + end + 2;
379                continue;
380            }
381        }
382        // Properly decode UTF-8 character (not byte-level cast)
383        let ch = command[i..].chars().next().unwrap();
384        result.push(ch);
385        i += ch.len_utf8();
386    }
387    result
388}
389
390// =========================================================================
391// Output sanitization
392// =========================================================================
393
394/// Strip ANSI escape sequences and C1 control codes from output.
395/// Handles CSI, OSC, DCS, SOS, PM and APC sequences plus the C1 range 0x80-0x9F.
396pub fn sanitize_output(input: &str) -> String {
397    let mut out = String::with_capacity(input.len());
398    let mut chars = input.chars().peekable();
399    while let Some(c) = chars.next() {
400        match c {
401            '\x1b' => {
402                match chars.peek() {
403                    Some('[') => {
404                        chars.next();
405                        // CSI: consume until 0x40-0x7E
406                        while let Some(&ch) = chars.peek() {
407                            chars.next();
408                            if ('\x40'..='\x7e').contains(&ch) {
409                                break;
410                            }
411                        }
412                    }
413                    Some(']') | Some('P') | Some('X') | Some('^') | Some('_') => {
414                        chars.next();
415                        // OSC/DCS/SOS/PM/APC: consume until ST (ESC\) or BEL
416                        consume_until_st(&mut chars);
417                    }
418                    _ => {
419                        // Single ESC + one char
420                        chars.next();
421                    }
422                }
423            }
424            '\u{009b}' => {
425                // 8-bit CSI (the single-char form of ESC[): consume the
426                // parameter bytes up to the final byte 0x40-0x7E so a colour or
427                // cursor sequence does not leak its parameters into the TUI as
428                // literal text. The terminator is bounded, so this never eats
429                // arbitrary trailing output (unlike a consume-until-ST string
430                // sequence, which the other C1 codes are left to strip
431                // byte-by-byte instead).
432                while let Some(&ch) = chars.peek() {
433                    chars.next();
434                    if ('\x40'..='\x7e').contains(&ch) {
435                        break;
436                    }
437                }
438            }
439            c if ('\u{0080}'..='\u{009F}').contains(&c) => {
440                // Other C1 control codes: skip the lone byte.
441            }
442            c if c.is_control() && c != '\n' && c != '\t' => {
443                // Other control chars (except newline/tab): skip
444            }
445            _ => out.push(c),
446        }
447    }
448    out
449}
450
451/// Consume chars until String Terminator (ESC\) or BEL (\x07).
452fn consume_until_st(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
453    while let Some(&ch) = chars.peek() {
454        if ch == '\x07' {
455            chars.next();
456            break;
457        }
458        if ch == '\x1b' {
459            chars.next();
460            if chars.peek() == Some(&'\\') {
461                chars.next();
462            }
463            break;
464        }
465        chars.next();
466    }
467}
468
469// =========================================================================
470// Background snippet execution
471// =========================================================================
472
473/// Maximum lines stored per host. Reader continues draining beyond this
474/// to prevent child from blocking on a full pipe buffer.
475const MAX_OUTPUT_LINES: usize = 10_000;
476
477/// How often [`ChildGuard::wait`] checks whether the child has exited. Short
478/// enough that a finished push feels immediate, long enough that waiting on a
479/// slow host costs no measurable CPU.
480const WAIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
481
482/// How long a child gets to act on SIGTERM before SIGKILL follows.
483const TERM_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
484
485/// RAII guard that kills the process group on drop.
486/// Uses SIGTERM first, then escalates to SIGKILL after a brief wait.
487pub struct ChildGuard {
488    inner: std::sync::Mutex<Option<std::process::Child>>,
489    pgid: i32,
490    /// When SIGTERM went out, if it has. The waiting thread reads this and
491    /// escalates once the grace window is up, so the caller that asked for
492    /// the kill does not have to stand there for it.
493    terminated_at: std::sync::Mutex<Option<std::time::Instant>>,
494}
495
496impl ChildGuard {
497    pub(crate) fn new(child: std::process::Child) -> Self {
498        // i32::try_from avoids silent overflow for PIDs > i32::MAX. Fallback 0
499        // is a sentinel the Drop guard treats as "no process group": it skips
500        // the group signal entirely (a negative pgid built from 0 or 1 would
501        // target our own group or PID 1/init) and falls back to child.kill().
502        // In practice Linux caps PIDs well below i32::MAX.
503        let pgid = i32::try_from(child.id()).unwrap_or(0);
504        Self {
505            inner: std::sync::Mutex::new(Some(child)),
506            pgid,
507            terminated_at: std::sync::Mutex::new(None),
508        }
509    }
510
511    /// Send SIGKILL to the process group. Caller holds no lock on `inner`.
512    #[cfg(unix)]
513    fn kill_group(&self) {
514        if self.pgid > 1 {
515            // SAFETY: self.pgid was set by setpgid(0, 0) in pre_exec and is
516            // valid for the lifetime of this guard. A negative PID signals
517            // the whole group. ESRCH is the expected race and is ignored.
518            unsafe {
519                libc::kill(-self.pgid, libc::SIGKILL);
520            }
521        }
522    }
523
524    #[cfg(not(unix))]
525    fn kill_group(&self) {}
526
527    /// Wait for the child to exit and take it out of the guard so a later
528    /// drop cannot signal a recycled PID. Returns `None` when the child was
529    /// already taken or the wait failed.
530    ///
531    /// Polls rather than blocking inside the lock, so a `terminate` from the
532    /// UI thread is not stuck behind this call. It also carries out the
533    /// SIGKILL escalation: `terminate` only signals, and this is the thread
534    /// that is standing here anyway.
535    ///
536    /// Sweeps the whole group once the child is reaped, whether the run was
537    /// stopped or ended on its own. ssh goes away first while a
538    /// `ProxyCommand` it spawned can linger, and that command inherits ssh's
539    /// stderr, so a reader blocked on the pipe stays blocked long after ssh
540    /// itself is gone. A group id stays reserved while any member is alive,
541    /// so the sweep reaches the stragglers it is meant for. Where the group
542    /// is already empty the signal does nothing, save for a window of a few
543    /// instructions after the reap in which the kernel could hand that same
544    /// id to a new group leader. `terminate` refuses the kill outright on
545    /// that risk; here the alternative is a reader parked on a pipe for as
546    /// long as the straggler lives, which is the worse of the two.
547    pub(crate) fn wait(&self) -> Option<ExitStatus> {
548        let mut escalated = false;
549        loop {
550            let reaped = {
551                let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
552                let child = lock.as_mut()?;
553                match child.try_wait() {
554                    Ok(Some(status)) => {
555                        let _ = lock.take();
556                        Some(Some(status))
557                    }
558                    Ok(None) => None,
559                    Err(_) => {
560                        let _ = lock.take();
561                        Some(None)
562                    }
563                }
564            };
565            if let Some(status) = reaped {
566                self.kill_group();
567                return status;
568            }
569            if !escalated && self.grace_expired() {
570                escalated = true;
571                self.kill_group();
572            }
573            std::thread::sleep(WAIT_POLL_INTERVAL);
574        }
575    }
576
577    /// True once SIGTERM has been sent and the child has had its grace
578    /// window to act on it.
579    fn grace_expired(&self) -> bool {
580        self.terminated_at
581            .lock()
582            .unwrap_or_else(|e| e.into_inner())
583            .is_some_and(|t| t.elapsed() >= TERM_GRACE)
584    }
585
586    /// Ask the process group to stop and return at once. SIGTERM goes out
587    /// now; the SIGKILL that follows the grace window is left to whichever
588    /// thread is in `wait`. Called from the UI thread on Esc and on quit, so
589    /// it must not stand and wait. A no-op once the child exited.
590    pub(crate) fn terminate(&self) {
591        let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
592        let Some(child) = lock.as_mut() else {
593            return;
594        };
595        // Already exited? Skip the kill entirely, the PID may be recycled.
596        if let Ok(Some(_)) = child.try_wait() {
597            return;
598        }
599        *self.terminated_at.lock().unwrap_or_else(|e| e.into_inner()) =
600            Some(std::time::Instant::now());
601        // Group signal only for a valid pgid (> 1). pgid 0 is the overflow
602        // sentinel; a negative pgid built from 0 or 1 would hit our own
603        // group or PID 1 (init), so those skip to the direct child.kill().
604        #[cfg(unix)]
605        if self.pgid > 1 {
606            // SAFETY: self.pgid was set by setpgid(0,0) in pre_exec and is
607            // valid for the lifetime of this guard. kill() with a negative
608            // PID signals the entire process group. ESRCH (already exited)
609            // is the expected race and the return value is ignored.
610            unsafe {
611                libc::kill(-self.pgid, libc::SIGTERM);
612            }
613            return;
614        }
615        // Fallback: direct kill in case setpgid failed in pre_exec or the
616        // pgid was the overflow sentinel. Nothing to escalate to afterwards.
617        let _ = child.kill();
618    }
619
620    /// Stop the child and stay until it is gone. For `Drop`, where no other
621    /// thread is left to carry out the escalation.
622    fn terminate_and_reap(&self) {
623        self.terminate();
624        let deadline = std::time::Instant::now() + TERM_GRACE;
625        loop {
626            {
627                let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
628                let Some(child) = lock.as_mut() else {
629                    return;
630                };
631                match child.try_wait() {
632                    Ok(Some(_)) | Err(_) => {
633                        let _ = lock.take();
634                        return;
635                    }
636                    Ok(None) => {}
637                }
638            }
639            if std::time::Instant::now() >= deadline {
640                break;
641            }
642            std::thread::sleep(WAIT_POLL_INTERVAL);
643        }
644        self.kill_group();
645        let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
646        if let Some(child) = lock.as_mut() {
647            let _ = child.kill();
648            let _ = child.wait();
649        }
650    }
651}
652
653impl Drop for ChildGuard {
654    fn drop(&mut self) {
655        // Nobody is left in `wait` to escalate, so the reap happens here.
656        self.terminate_and_reap();
657    }
658}
659
660/// Read a pipe into a String under two hard bounds: a total byte cap
661/// (`SSH_OUTPUT_MAX_BYTES`) and a line cap (`MAX_OUTPUT_LINES`). The byte cap is
662/// applied first via [`read_bounded`], which reads fixed chunks and drains the
663/// remainder to a sink once the cap is hit, so a single newline-free megastream
664/// from a hostile remote can never buffer unbounded memory. The bounded bytes
665/// are then split into at most `MAX_OUTPUT_LINES` lines.
666fn read_pipe_capped<R: io::Read>(reader: R, alias: &str, stream: &str) -> String {
667    use io::BufRead;
668    let mut br = io::BufReader::new(reader);
669    let bounded = read_bounded(&mut br, SSH_OUTPUT_MAX_BYTES, alias, stream);
670
671    let mut output = String::new();
672    let mut line_count = 0;
673    let mut rdr = io::BufReader::new(bounded.as_slice());
674    let mut buf = Vec::new();
675    loop {
676        buf.clear();
677        match rdr.read_until(b'\n', &mut buf) {
678            Ok(0) => break, // EOF
679            Ok(_) => {
680                if line_count < MAX_OUTPUT_LINES {
681                    if line_count > 0 {
682                        output.push('\n');
683                    }
684                    // Strip trailing newline (and \r for CRLF)
685                    if buf.last() == Some(&b'\n') {
686                        buf.pop();
687                        if buf.last() == Some(&b'\r') {
688                            buf.pop();
689                        }
690                    }
691                    // Lossy conversion handles non-UTF-8 output
692                    output.push_str(&String::from_utf8_lossy(&buf));
693                    line_count += 1;
694                } else {
695                    output.push_str("\n[Output truncated at 10,000 lines]");
696                    break;
697                }
698            }
699            Err(_) => break,
700        }
701    }
702    output
703}
704
705/// Build the base SSH command with shared options for snippet execution.
706/// Sets -F, ConnectTimeout, ControlMaster/ControlPath and ClearAllForwardings.
707/// Also configures askpass and Bitwarden session env vars.
708///
709/// When `non_interactive` is true, the child can never reach the controlling
710/// tty: `-o StrictHostKeyChecking=yes` turns an unknown host into an error
711/// (`accept-new` when `trust_new_host_key` is set by a retry after the trust
712/// dialog) and `-o BatchMode=yes` turns a password prompt into `Permission
713/// denied` when no source and no session password can answer it. Background
714/// fetches (container listings, file browser listings, captured snippet
715/// output) pass `true`. Direct CLI use passes `false` so users retain normal
716/// host-key trust-on-first-use behavior and ssh's own prompt.
717#[allow(clippy::too_many_arguments)]
718fn base_ssh_command(
719    alias: &str,
720    config_path: &Path,
721    command: &str,
722    askpass: Option<&str>,
723    session_password: Option<&str>,
724    bw_session: Option<&str>,
725    has_active_tunnel: bool,
726    non_interactive: bool,
727    trust_new_host_key: bool,
728) -> Command {
729    let mut cmd = Command::new("ssh");
730    cmd.arg("-F")
731        .arg(config_path)
732        .arg("-o")
733        .arg("ConnectTimeout=10")
734        .arg("-o")
735        .arg("ControlMaster=no")
736        .arg("-o")
737        .arg("ControlPath=none");
738
739    if non_interactive {
740        let strict = if trust_new_host_key {
741            "StrictHostKeyChecking=accept-new"
742        } else {
743            "StrictHostKeyChecking=yes"
744        };
745        cmd.arg("-o")
746            .arg(strict)
747            .arg("-o")
748            .arg(crate::askpass_env::SINGLE_PASSWORD_PROMPT_OPT);
749        if crate::askpass_env::needs_batch_mode(askpass, session_password) {
750            cmd.arg("-o").arg(crate::askpass_env::BATCH_MODE_OPT);
751        }
752    }
753
754    if has_active_tunnel {
755        cmd.arg("-o").arg("ClearAllForwardings=yes");
756    }
757
758    cmd.arg("--").arg(alias).arg(command);
759
760    crate::askpass_env::configure_auth(
761        &mut cmd,
762        alias,
763        config_path,
764        askpass,
765        session_password,
766        bw_session,
767        non_interactive,
768    );
769
770    cmd
771}
772
773/// Build the SSH Command for a snippet execution with piped I/O.
774fn build_snippet_command(ctx: &crate::ssh_context::SshContext<'_>, command: &str) -> Command {
775    let mut cmd = base_ssh_command(
776        ctx.alias,
777        ctx.config_path,
778        command,
779        ctx.askpass,
780        ctx.session_password,
781        ctx.bw_session,
782        ctx.has_tunnel,
783        true,
784        ctx.trust_new_host_key,
785    );
786    cmd.stdin(Stdio::null())
787        .stdout(Stdio::piped())
788        .stderr(Stdio::piped());
789
790    // Isolate child into its own process group so we can kill the
791    // entire tree without affecting purple itself.
792    #[cfg(unix)]
793    // SAFETY: the pre-fork callback runs between fork and the exec syscall in
794    // the child; only async-signal-safe calls are permitted. `setpgid(0, 0)`
795    // is async-signal-safe per POSIX and does not touch Rust runtime state.
796    unsafe {
797        use std::os::unix::process::CommandExt;
798        cmd.pre_exec(|| {
799            libc::setpgid(0, 0);
800            Ok(())
801        });
802    }
803
804    cmd
805}
806
807/// Execute a single host: spawn SSH, read output, wait, send result.
808fn execute_host(
809    run_id: u64,
810    ctx: &crate::ssh_context::SshContext<'_>,
811    command: &str,
812    tx: &std::sync::mpsc::Sender<crate::event::AppEvent>,
813) -> Option<std::sync::Arc<ChildGuard>> {
814    let alias = ctx.alias;
815    let mut cmd = build_snippet_command(ctx, command);
816
817    match cmd.spawn() {
818        Ok(child) => {
819            let guard = std::sync::Arc::new(ChildGuard::new(child));
820
821            // Take stdout/stderr BEFORE wait to avoid pipe deadlock
822            let stdout_pipe = {
823                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
824                lock.as_mut().and_then(|c| c.stdout.take())
825            };
826            let stderr_pipe = {
827                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
828                lock.as_mut().and_then(|c| c.stderr.take())
829            };
830
831            // Spawn reader threads
832            let alias_out = alias.to_string();
833            let stdout_handle = std::thread::spawn(move || match stdout_pipe {
834                Some(pipe) => read_pipe_capped(pipe, &alias_out, "stdout"),
835                None => String::new(),
836            });
837            let alias_err = alias.to_string();
838            let stderr_handle = std::thread::spawn(move || match stderr_pipe {
839                Some(pipe) => read_pipe_capped(pipe, &alias_err, "stderr"),
840                None => String::new(),
841            });
842
843            // Join readers BEFORE wait to guarantee all output is received
844            let stdout_text = stdout_handle.join().unwrap_or_else(|_| {
845                log::warn!("[purple] Snippet stdout reader thread panicked");
846                String::new()
847            });
848            let stderr_text = stderr_handle.join().unwrap_or_else(|_| {
849                log::warn!("[purple] Snippet stderr reader thread panicked");
850                String::new()
851            });
852
853            // Now wait for the child to exit, then take it out of the
854            // guard so Drop won't kill a potentially recycled PID.
855            let exit_code = {
856                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
857                let status = lock.as_mut().and_then(|c| c.wait().ok());
858                let _ = lock.take(); // Prevent ChildGuard::drop from killing recycled PID
859                status.and_then(|s| {
860                    #[cfg(unix)]
861                    {
862                        use std::os::unix::process::ExitStatusExt;
863                        s.code().or_else(|| s.signal().map(|sig| 128 + sig))
864                    }
865                    #[cfg(not(unix))]
866                    {
867                        s.code()
868                    }
869                })
870            };
871
872            let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
873                run_id,
874                alias: alias.to_string(),
875                stdout: sanitize_output(&stdout_text),
876                stderr: sanitize_output(&stderr_text),
877                exit_code,
878            });
879
880            Some(guard)
881        }
882        Err(e) => {
883            log::warn!(
884                "[external] snippet ssh spawn failed: run_id={} alias={} err={}",
885                run_id,
886                alias,
887                e
888            );
889            let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
890                run_id,
891                alias: alias.to_string(),
892                stdout: String::new(),
893                stderr: crate::messages::snippet_ssh_launch_failed(&e),
894                exit_code: None,
895            });
896            None
897        }
898    }
899}
900
901/// Spawn background snippet execution on multiple hosts.
902/// The coordinator thread drives sequential or parallel host iteration.
903#[allow(clippy::too_many_arguments)]
904pub fn spawn_snippet_execution(
905    run_id: u64,
906    askpass_map: Vec<(String, Option<String>)>,
907    config_path: PathBuf,
908    env: std::sync::Arc<crate::runtime::env::Env>,
909    command: String,
910    bw_session: Option<String>,
911    tunnel_aliases: std::collections::HashSet<String>,
912    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
913    tx: std::sync::mpsc::Sender<crate::event::AppEvent>,
914    parallel: bool,
915) {
916    let total = askpass_map.len();
917    let max_concurrent: usize = 20;
918
919    std::thread::Builder::new()
920        .name("snippet-coordinator".into())
921        .spawn(move || {
922            let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
923                std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
924
925            if parallel && total > 1 {
926                // Slot-based semaphore for concurrency limiting
927                let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
928                for _ in 0..max_concurrent.min(total) {
929                    let _ = slot_tx.send(());
930                }
931
932                let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
933                let mut worker_handles = Vec::new();
934
935                for (alias, askpass) in askpass_map {
936                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
937                        break;
938                    }
939
940                    // Wait for a slot, checking cancel periodically
941                    loop {
942                        match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
943                            Ok(()) => break,
944                            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
945                                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
946                                    break;
947                                }
948                            }
949                            Err(_) => break, // channel closed
950                        }
951                    }
952
953                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
954                        break;
955                    }
956
957                    let config_path = config_path.clone();
958                    let env = std::sync::Arc::clone(&env);
959                    let command = command.clone();
960                    let bw_session = bw_session.clone();
961                    let has_tunnel = tunnel_aliases.contains(&alias);
962                    let tx = tx.clone();
963                    let slot_tx = slot_tx.clone();
964                    let guards = guards.clone();
965                    let completed = completed.clone();
966                    let total = total;
967
968                    let handle = std::thread::spawn(move || {
969                        // RAII guard: release semaphore slot even on panic
970                        struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
971                        impl Drop for SlotRelease {
972                            fn drop(&mut self) {
973                                if let Some(tx) = self.0.take() {
974                                    let _ = tx.send(());
975                                }
976                            }
977                        }
978                        let _slot = SlotRelease(Some(slot_tx));
979
980                        let host_ctx = crate::ssh_context::SshContext {
981                            alias: &alias,
982                            config_path: &config_path,
983                            askpass: askpass.as_deref(),
984                            session_password: None,
985                            bw_session: bw_session.as_deref(),
986                            has_tunnel,
987                            trust_new_host_key: false,
988                            env: &env,
989                        };
990                        let guard = execute_host(run_id, &host_ctx, &command, &tx);
991
992                        // Insert guard BEFORE checking cancel so it can be cleaned up
993                        if let Some(g) = guard {
994                            guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
995                        }
996
997                        let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
998                        let _ = tx.send(crate::event::AppEvent::SnippetProgress {
999                            run_id,
1000                            completed: c,
1001                            total,
1002                        });
1003                        // _slot dropped here, releasing semaphore
1004                    });
1005                    worker_handles.push(handle);
1006                }
1007
1008                // Wait for all workers to finish
1009                for handle in worker_handles {
1010                    let _ = handle.join();
1011                }
1012            } else {
1013                // Sequential execution
1014                for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
1015                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1016                        break;
1017                    }
1018
1019                    let has_tunnel = tunnel_aliases.contains(&alias);
1020                    let host_ctx = crate::ssh_context::SshContext {
1021                        alias: &alias,
1022                        config_path: &config_path,
1023                        askpass: askpass.as_deref(),
1024                        session_password: None,
1025                        bw_session: bw_session.as_deref(),
1026                        has_tunnel,
1027                        trust_new_host_key: false,
1028                        env: &env,
1029                    };
1030                    let guard = execute_host(run_id, &host_ctx, &command, &tx);
1031
1032                    if let Some(g) = guard {
1033                        guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
1034                    }
1035
1036                    let _ = tx.send(crate::event::AppEvent::SnippetProgress {
1037                        run_id,
1038                        completed: i + 1,
1039                        total,
1040                    });
1041                }
1042            }
1043
1044            let _ = tx.send(crate::event::AppEvent::SnippetAllDone { run_id });
1045            // Guards dropped here, cleaning up any remaining children
1046        })
1047        .expect("failed to spawn snippet coordinator");
1048}
1049
1050/// Run a snippet on a single host via SSH.
1051/// When `capture` is true, stdout/stderr are piped and returned in the result.
1052/// When `capture` is false, stdout/stderr are inherited (streamed to terminal
1053/// in real-time) and the returned strings are empty.
1054#[allow(clippy::too_many_arguments)]
1055pub fn run_snippet(
1056    alias: &str,
1057    config_path: &Path,
1058    env: &crate::runtime::env::Env,
1059    command: &str,
1060    askpass: Option<&str>,
1061    bw_session: Option<&str>,
1062    capture: bool,
1063    has_active_tunnel: bool,
1064) -> anyhow::Result<SnippetResult> {
1065    let ctx = crate::ssh_context::SshContext {
1066        alias,
1067        config_path,
1068        askpass,
1069        session_password: None,
1070        bw_session,
1071        has_tunnel: has_active_tunnel,
1072        trust_new_host_key: false,
1073        env,
1074    };
1075    run_snippet_ctx(&ctx, command, capture)
1076}
1077
1078/// `run_snippet` for callers that already hold an
1079/// [`SshContext`](crate::ssh_context::SshContext). The context
1080/// carries the session password and the trust flag, so background fetches
1081/// (file browser, containers) authenticate the way the TUI decided.
1082pub fn run_snippet_ctx(
1083    ctx: &crate::ssh_context::SshContext<'_>,
1084    command: &str,
1085    capture: bool,
1086) -> anyhow::Result<SnippetResult> {
1087    let alias = ctx.alias;
1088    // Renew the Vault SSH cert before connecting so container listing,
1089    // inspect, logs, actions and file-browser operations get a fresh cert
1090    // just like the interactive connect path does. No-op for non-vault hosts.
1091    crate::runtime::helpers::ensure_vault_cert_for_alias(ctx.env, alias, ctx.config_path);
1092
1093    let mut cmd = base_ssh_command(
1094        alias,
1095        ctx.config_path,
1096        command,
1097        ctx.askpass,
1098        ctx.session_password,
1099        ctx.bw_session,
1100        ctx.has_tunnel,
1101        capture,
1102        ctx.trust_new_host_key,
1103    );
1104
1105    if capture {
1106        cmd.stdin(Stdio::null())
1107            .stdout(Stdio::piped())
1108            .stderr(Stdio::piped());
1109    } else {
1110        cmd.stdin(Stdio::inherit())
1111            .stdout(Stdio::inherit())
1112            .stderr(Stdio::inherit());
1113    }
1114
1115    if capture {
1116        let (status, stdout, stderr) = run_with_bounded_output(&mut cmd, alias)?;
1117        Ok(SnippetResult {
1118            status,
1119            stdout,
1120            stderr,
1121        })
1122    } else {
1123        let status = cmd
1124            .status()
1125            .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
1126
1127        Ok(SnippetResult {
1128            status,
1129            stdout: String::new(),
1130            stderr: String::new(),
1131        })
1132    }
1133}
1134
1135/// Hard cap on stdout/stderr captured from an SSH child process.
1136/// A hostile or malfunctioning remote can stream unbounded output and
1137/// blow up purple's memory. 16 MB is far above any legitimate
1138/// `docker inspect` / `docker logs --tail 200` / `ps -a` output (which
1139/// peak at hundreds of KB) and below what would meaningfully stress
1140/// the parse pipelines or terminal buffer.
1141pub const SSH_OUTPUT_MAX_BYTES: usize = 16 * 1024 * 1024;
1142
1143/// Spawn a child with piped stdout/stderr and read each pipe with a
1144/// hard byte cap. Once the cap is hit the remaining bytes are drained
1145/// to a sink (so the child can exit cleanly) and a debug log records
1146/// the truncation. Returns the captured payload as lossy-UTF8 Strings
1147/// alongside the child's exit status.
1148fn run_with_bounded_output(
1149    cmd: &mut Command,
1150    alias: &str,
1151) -> anyhow::Result<(ExitStatus, String, String)> {
1152    let mut child = cmd
1153        .spawn()
1154        .map_err(|e| anyhow::anyhow!("Failed to spawn ssh for '{}': {}", alias, e))?;
1155    let stdout = child.stdout.take();
1156    let stderr = child.stderr.take();
1157
1158    let alias_for_stdout = alias.to_string();
1159    let stdout_handle = std::thread::spawn(move || match stdout {
1160        Some(mut pipe) => {
1161            read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stdout, "stdout")
1162        }
1163        None => Vec::new(),
1164    });
1165    let alias_for_stderr = alias.to_string();
1166    let stderr_handle = std::thread::spawn(move || match stderr {
1167        Some(mut pipe) => {
1168            read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stderr, "stderr")
1169        }
1170        None => Vec::new(),
1171    });
1172
1173    // Join the drain threads BEFORE waiting on the child. The threads
1174    // own the pipe read-ends; when they finish (cap hit or EOF) the
1175    // reader handle drops and closes its side of the pipe, which
1176    // unblocks any pending write on the remote child. Waiting on the
1177    // child first would deadlock the moment stdout exceeded the kernel
1178    // pipe buffer (typically 64 KB on Linux): the child blocks on
1179    // write, the parent blocks on wait, neither thread runs.
1180    let stdout_bytes = stdout_handle.join().unwrap_or_default();
1181    let stderr_bytes = stderr_handle.join().unwrap_or_default();
1182    let status = child
1183        .wait()
1184        .map_err(|e| anyhow::anyhow!("ssh wait failed for '{}': {}", alias, e))?;
1185
1186    Ok((
1187        status,
1188        String::from_utf8_lossy(&stdout_bytes).to_string(),
1189        String::from_utf8_lossy(&stderr_bytes).to_string(),
1190    ))
1191}
1192
1193fn read_bounded<R: std::io::Read>(
1194    reader: &mut R,
1195    max: usize,
1196    alias: &str,
1197    stream: &str,
1198) -> Vec<u8> {
1199    let mut out = Vec::with_capacity(8 * 1024);
1200    let mut chunk = [0u8; 8 * 1024];
1201    loop {
1202        match reader.read(&mut chunk) {
1203            Ok(0) => break,
1204            Ok(n) => {
1205                if out.len() + n > max {
1206                    let allowed = max.saturating_sub(out.len());
1207                    out.extend_from_slice(&chunk[..allowed]);
1208                    log::warn!(
1209                        "[external] ssh {} for '{}' exceeded {} bytes; truncating remainder",
1210                        stream,
1211                        alias,
1212                        max
1213                    );
1214                    // Drain remaining bytes so the child can exit cleanly
1215                    // instead of blocking on a backpressured pipe.
1216                    let _ = std::io::copy(reader, &mut std::io::sink());
1217                    break;
1218                }
1219                out.extend_from_slice(&chunk[..n]);
1220            }
1221            Err(e) => {
1222                log::debug!("[external] ssh {stream} read error for '{alias}': {e}");
1223                break;
1224            }
1225        }
1226    }
1227    out
1228}
1229
1230/// Static, execution-free blast-radius analysis of a snippet command for the
1231/// Snippets detail IMPACT card. Implemented in [`crate::snippet_impact`]
1232/// (quote-aware segmenter + curated capability table); re-exported here so the
1233/// `crate::snippet::analyze_command` path stays stable.
1234pub use crate::snippet_impact::{Category, CommandImpact, Finding, Severity, analyze_command};
1235
1236/// Indices of snippets in `store` matching `query` by name, command or
1237/// description (case-insensitive). A `None` or empty query returns every index
1238/// in order. Shared by the host-list snippet picker and the Snippets tab.
1239pub fn filtered_indices(store: &SnippetStore, query: Option<&str>) -> Vec<usize> {
1240    match query {
1241        None | Some("") => (0..store.snippets.len()).collect(),
1242        Some(q) => store
1243            .snippets
1244            .iter()
1245            .enumerate()
1246            .filter(|(_, s)| {
1247                crate::app::contains_ci(&s.name, q)
1248                    || crate::app::contains_ci(&s.command, q)
1249                    || crate::app::contains_ci(&s.description, q)
1250            })
1251            .map(|(i, _)| i)
1252            .collect(),
1253    }
1254}
1255
1256#[cfg(test)]
1257#[path = "snippet_tests.rs"]
1258mod tests;