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/// RAII guard that kills the process group on drop.
478/// Uses SIGTERM first, then escalates to SIGKILL after a brief wait.
479pub struct ChildGuard {
480    inner: std::sync::Mutex<Option<std::process::Child>>,
481    pgid: i32,
482}
483
484impl ChildGuard {
485    fn new(child: std::process::Child) -> Self {
486        // i32::try_from avoids silent overflow for PIDs > i32::MAX. Fallback 0
487        // is a sentinel the Drop guard treats as "no process group": it skips
488        // the group signal entirely (a negative pgid built from 0 or 1 would
489        // target our own group or PID 1/init) and falls back to child.kill().
490        // In practice Linux caps PIDs well below i32::MAX.
491        let pgid = i32::try_from(child.id()).unwrap_or(0);
492        Self {
493            inner: std::sync::Mutex::new(Some(child)),
494            pgid,
495        }
496    }
497}
498
499impl Drop for ChildGuard {
500    fn drop(&mut self) {
501        let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
502        if let Some(ref mut child) = *lock {
503            // Already exited? Skip kill entirely (PID may be recycled).
504            if let Ok(Some(_)) = child.try_wait() {
505                return;
506            }
507            // Group signal only for a valid pgid (> 1). pgid 0 is the overflow
508            // sentinel; a negative pgid built from 0 or 1 would hit our own
509            // group or PID 1 (init), so skip straight to the direct child.kill().
510            #[cfg(unix)]
511            if self.pgid > 1 {
512                // SAFETY: self.pgid was set by setpgid(0,0) in pre_exec and is
513                // valid for the lifetime of this SnippetChild. kill() with a
514                // negative PID sends the signal to the entire process group.
515                // ESRCH (process already exited) is the expected race; the
516                // return value is intentionally ignored.
517                unsafe {
518                    libc::kill(-self.pgid, libc::SIGTERM);
519                }
520                // Poll for up to 500ms
521                let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
522                loop {
523                    if let Ok(Some(_)) = child.try_wait() {
524                        return;
525                    }
526                    if std::time::Instant::now() >= deadline {
527                        break;
528                    }
529                    std::thread::sleep(std::time::Duration::from_millis(50));
530                }
531                // SAFETY: same invariants as the SIGTERM call above.
532                unsafe {
533                    libc::kill(-self.pgid, libc::SIGKILL);
534                }
535            }
536            // Fallback: direct kill in case setpgid failed in pre_exec or the
537            // pgid was the overflow sentinel.
538            let _ = child.kill();
539            let _ = child.wait();
540        }
541    }
542}
543
544/// Read a pipe into a String under two hard bounds: a total byte cap
545/// (`SSH_OUTPUT_MAX_BYTES`) and a line cap (`MAX_OUTPUT_LINES`). The byte cap is
546/// applied first via [`read_bounded`], which reads fixed chunks and drains the
547/// remainder to a sink once the cap is hit, so a single newline-free megastream
548/// from a hostile remote can never buffer unbounded memory. The bounded bytes
549/// are then split into at most `MAX_OUTPUT_LINES` lines.
550fn read_pipe_capped<R: io::Read>(reader: R, alias: &str, stream: &str) -> String {
551    use io::BufRead;
552    let mut br = io::BufReader::new(reader);
553    let bounded = read_bounded(&mut br, SSH_OUTPUT_MAX_BYTES, alias, stream);
554
555    let mut output = String::new();
556    let mut line_count = 0;
557    let mut rdr = io::BufReader::new(bounded.as_slice());
558    let mut buf = Vec::new();
559    loop {
560        buf.clear();
561        match rdr.read_until(b'\n', &mut buf) {
562            Ok(0) => break, // EOF
563            Ok(_) => {
564                if line_count < MAX_OUTPUT_LINES {
565                    if line_count > 0 {
566                        output.push('\n');
567                    }
568                    // Strip trailing newline (and \r for CRLF)
569                    if buf.last() == Some(&b'\n') {
570                        buf.pop();
571                        if buf.last() == Some(&b'\r') {
572                            buf.pop();
573                        }
574                    }
575                    // Lossy conversion handles non-UTF-8 output
576                    output.push_str(&String::from_utf8_lossy(&buf));
577                    line_count += 1;
578                } else {
579                    output.push_str("\n[Output truncated at 10,000 lines]");
580                    break;
581                }
582            }
583            Err(_) => break,
584        }
585    }
586    output
587}
588
589/// Build the base SSH command with shared options for snippet execution.
590/// Sets -F, ConnectTimeout, ControlMaster/ControlPath and ClearAllForwardings.
591/// Also configures askpass and Bitwarden session env vars.
592///
593/// When `non_interactive` is true, adds `-o StrictHostKeyChecking=yes` so an
594/// unknown host returns an error instead of writing a prompt to the controlling
595/// tty. Background fetches (container listings, file browser listings, captured
596/// snippet output) pass `true`. Direct CLI use passes `false` so users retain
597/// normal host-key trust-on-first-use behaviour.
598fn base_ssh_command(
599    alias: &str,
600    config_path: &Path,
601    command: &str,
602    askpass: Option<&str>,
603    bw_session: Option<&str>,
604    has_active_tunnel: bool,
605    non_interactive: bool,
606) -> Command {
607    let mut cmd = Command::new("ssh");
608    cmd.arg("-F")
609        .arg(config_path)
610        .arg("-o")
611        .arg("ConnectTimeout=10")
612        .arg("-o")
613        .arg("ControlMaster=no")
614        .arg("-o")
615        .arg("ControlPath=none");
616
617    if non_interactive {
618        cmd.arg("-o").arg("StrictHostKeyChecking=yes");
619    }
620
621    if has_active_tunnel {
622        cmd.arg("-o").arg("ClearAllForwardings=yes");
623    }
624
625    cmd.arg("--").arg(alias).arg(command);
626
627    if askpass.is_some() {
628        crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
629    }
630
631    if let Some(token) = bw_session {
632        cmd.env("BW_SESSION", token);
633    }
634
635    cmd
636}
637
638/// Build the SSH Command for a snippet execution with piped I/O.
639fn build_snippet_command(
640    alias: &str,
641    config_path: &Path,
642    command: &str,
643    askpass: Option<&str>,
644    bw_session: Option<&str>,
645    has_active_tunnel: bool,
646) -> Command {
647    let mut cmd = base_ssh_command(
648        alias,
649        config_path,
650        command,
651        askpass,
652        bw_session,
653        has_active_tunnel,
654        true,
655    );
656    cmd.stdin(Stdio::null())
657        .stdout(Stdio::piped())
658        .stderr(Stdio::piped());
659
660    // Isolate child into its own process group so we can kill the
661    // entire tree without affecting purple itself.
662    #[cfg(unix)]
663    // SAFETY: the pre-fork callback runs between fork and the exec syscall in
664    // the child; only async-signal-safe calls are permitted. `setpgid(0, 0)`
665    // is async-signal-safe per POSIX and does not touch Rust runtime state.
666    unsafe {
667        use std::os::unix::process::CommandExt;
668        cmd.pre_exec(|| {
669            libc::setpgid(0, 0);
670            Ok(())
671        });
672    }
673
674    cmd
675}
676
677/// Execute a single host: spawn SSH, read output, wait, send result.
678fn execute_host(
679    run_id: u64,
680    ctx: &crate::ssh_context::SshContext<'_>,
681    command: &str,
682    tx: &std::sync::mpsc::Sender<crate::event::AppEvent>,
683) -> Option<std::sync::Arc<ChildGuard>> {
684    let alias = ctx.alias;
685    let mut cmd = build_snippet_command(
686        alias,
687        ctx.config_path,
688        command,
689        ctx.askpass,
690        ctx.bw_session,
691        ctx.has_tunnel,
692    );
693
694    match cmd.spawn() {
695        Ok(child) => {
696            let guard = std::sync::Arc::new(ChildGuard::new(child));
697
698            // Take stdout/stderr BEFORE wait to avoid pipe deadlock
699            let stdout_pipe = {
700                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
701                lock.as_mut().and_then(|c| c.stdout.take())
702            };
703            let stderr_pipe = {
704                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
705                lock.as_mut().and_then(|c| c.stderr.take())
706            };
707
708            // Spawn reader threads
709            let alias_out = alias.to_string();
710            let stdout_handle = std::thread::spawn(move || match stdout_pipe {
711                Some(pipe) => read_pipe_capped(pipe, &alias_out, "stdout"),
712                None => String::new(),
713            });
714            let alias_err = alias.to_string();
715            let stderr_handle = std::thread::spawn(move || match stderr_pipe {
716                Some(pipe) => read_pipe_capped(pipe, &alias_err, "stderr"),
717                None => String::new(),
718            });
719
720            // Join readers BEFORE wait to guarantee all output is received
721            let stdout_text = stdout_handle.join().unwrap_or_else(|_| {
722                log::warn!("[purple] Snippet stdout reader thread panicked");
723                String::new()
724            });
725            let stderr_text = stderr_handle.join().unwrap_or_else(|_| {
726                log::warn!("[purple] Snippet stderr reader thread panicked");
727                String::new()
728            });
729
730            // Now wait for the child to exit, then take it out of the
731            // guard so Drop won't kill a potentially recycled PID.
732            let exit_code = {
733                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
734                let status = lock.as_mut().and_then(|c| c.wait().ok());
735                let _ = lock.take(); // Prevent ChildGuard::drop from killing recycled PID
736                status.and_then(|s| {
737                    #[cfg(unix)]
738                    {
739                        use std::os::unix::process::ExitStatusExt;
740                        s.code().or_else(|| s.signal().map(|sig| 128 + sig))
741                    }
742                    #[cfg(not(unix))]
743                    {
744                        s.code()
745                    }
746                })
747            };
748
749            let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
750                run_id,
751                alias: alias.to_string(),
752                stdout: sanitize_output(&stdout_text),
753                stderr: sanitize_output(&stderr_text),
754                exit_code,
755            });
756
757            Some(guard)
758        }
759        Err(e) => {
760            log::warn!(
761                "[external] snippet ssh spawn failed: run_id={} alias={} err={}",
762                run_id,
763                alias,
764                e
765            );
766            let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
767                run_id,
768                alias: alias.to_string(),
769                stdout: String::new(),
770                stderr: crate::messages::snippet_ssh_launch_failed(&e),
771                exit_code: None,
772            });
773            None
774        }
775    }
776}
777
778/// Spawn background snippet execution on multiple hosts.
779/// The coordinator thread drives sequential or parallel host iteration.
780#[allow(clippy::too_many_arguments)]
781pub fn spawn_snippet_execution(
782    run_id: u64,
783    askpass_map: Vec<(String, Option<String>)>,
784    config_path: PathBuf,
785    env: std::sync::Arc<crate::runtime::env::Env>,
786    command: String,
787    bw_session: Option<String>,
788    tunnel_aliases: std::collections::HashSet<String>,
789    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
790    tx: std::sync::mpsc::Sender<crate::event::AppEvent>,
791    parallel: bool,
792) {
793    let total = askpass_map.len();
794    let max_concurrent: usize = 20;
795
796    std::thread::Builder::new()
797        .name("snippet-coordinator".into())
798        .spawn(move || {
799            let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
800                std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
801
802            if parallel && total > 1 {
803                // Slot-based semaphore for concurrency limiting
804                let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
805                for _ in 0..max_concurrent.min(total) {
806                    let _ = slot_tx.send(());
807                }
808
809                let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
810                let mut worker_handles = Vec::new();
811
812                for (alias, askpass) in askpass_map {
813                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
814                        break;
815                    }
816
817                    // Wait for a slot, checking cancel periodically
818                    loop {
819                        match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
820                            Ok(()) => break,
821                            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
822                                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
823                                    break;
824                                }
825                            }
826                            Err(_) => break, // channel closed
827                        }
828                    }
829
830                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
831                        break;
832                    }
833
834                    let config_path = config_path.clone();
835                    let env = std::sync::Arc::clone(&env);
836                    let command = command.clone();
837                    let bw_session = bw_session.clone();
838                    let has_tunnel = tunnel_aliases.contains(&alias);
839                    let tx = tx.clone();
840                    let slot_tx = slot_tx.clone();
841                    let guards = guards.clone();
842                    let completed = completed.clone();
843                    let total = total;
844
845                    let handle = std::thread::spawn(move || {
846                        // RAII guard: release semaphore slot even on panic
847                        struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
848                        impl Drop for SlotRelease {
849                            fn drop(&mut self) {
850                                if let Some(tx) = self.0.take() {
851                                    let _ = tx.send(());
852                                }
853                            }
854                        }
855                        let _slot = SlotRelease(Some(slot_tx));
856
857                        let host_ctx = crate::ssh_context::SshContext {
858                            alias: &alias,
859                            config_path: &config_path,
860                            askpass: askpass.as_deref(),
861                            bw_session: bw_session.as_deref(),
862                            has_tunnel,
863                            env: &env,
864                        };
865                        let guard = execute_host(run_id, &host_ctx, &command, &tx);
866
867                        // Insert guard BEFORE checking cancel so it can be cleaned up
868                        if let Some(g) = guard {
869                            guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
870                        }
871
872                        let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
873                        let _ = tx.send(crate::event::AppEvent::SnippetProgress {
874                            run_id,
875                            completed: c,
876                            total,
877                        });
878                        // _slot dropped here, releasing semaphore
879                    });
880                    worker_handles.push(handle);
881                }
882
883                // Wait for all workers to finish
884                for handle in worker_handles {
885                    let _ = handle.join();
886                }
887            } else {
888                // Sequential execution
889                for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
890                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
891                        break;
892                    }
893
894                    let has_tunnel = tunnel_aliases.contains(&alias);
895                    let host_ctx = crate::ssh_context::SshContext {
896                        alias: &alias,
897                        config_path: &config_path,
898                        askpass: askpass.as_deref(),
899                        bw_session: bw_session.as_deref(),
900                        has_tunnel,
901                        env: &env,
902                    };
903                    let guard = execute_host(run_id, &host_ctx, &command, &tx);
904
905                    if let Some(g) = guard {
906                        guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
907                    }
908
909                    let _ = tx.send(crate::event::AppEvent::SnippetProgress {
910                        run_id,
911                        completed: i + 1,
912                        total,
913                    });
914                }
915            }
916
917            let _ = tx.send(crate::event::AppEvent::SnippetAllDone { run_id });
918            // Guards dropped here, cleaning up any remaining children
919        })
920        .expect("failed to spawn snippet coordinator");
921}
922
923/// Run a snippet on a single host via SSH.
924/// When `capture` is true, stdout/stderr are piped and returned in the result.
925/// When `capture` is false, stdout/stderr are inherited (streamed to terminal
926/// in real-time) and the returned strings are empty.
927#[allow(clippy::too_many_arguments)]
928pub fn run_snippet(
929    alias: &str,
930    config_path: &Path,
931    env: &crate::runtime::env::Env,
932    command: &str,
933    askpass: Option<&str>,
934    bw_session: Option<&str>,
935    capture: bool,
936    has_active_tunnel: bool,
937) -> anyhow::Result<SnippetResult> {
938    // Renew the Vault SSH cert before connecting so container listing,
939    // inspect, logs, actions and file-browser operations get a fresh cert
940    // just like the interactive connect path does. No-op for non-vault hosts.
941    crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
942
943    let mut cmd = base_ssh_command(
944        alias,
945        config_path,
946        command,
947        askpass,
948        bw_session,
949        has_active_tunnel,
950        capture,
951    );
952
953    if capture {
954        cmd.stdin(Stdio::null())
955            .stdout(Stdio::piped())
956            .stderr(Stdio::piped());
957    } else {
958        cmd.stdin(Stdio::inherit())
959            .stdout(Stdio::inherit())
960            .stderr(Stdio::inherit());
961    }
962
963    if capture {
964        let (status, stdout, stderr) = run_with_bounded_output(&mut cmd, alias)?;
965        Ok(SnippetResult {
966            status,
967            stdout,
968            stderr,
969        })
970    } else {
971        let status = cmd
972            .status()
973            .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
974
975        Ok(SnippetResult {
976            status,
977            stdout: String::new(),
978            stderr: String::new(),
979        })
980    }
981}
982
983/// Hard cap on stdout/stderr captured from an SSH child process.
984/// A hostile or malfunctioning remote can stream unbounded output and
985/// blow up purple's memory. 16 MB is far above any legitimate
986/// `docker inspect` / `docker logs --tail 200` / `ps -a` output (which
987/// peak at hundreds of KB) and below what would meaningfully stress
988/// the parse pipelines or terminal buffer.
989pub const SSH_OUTPUT_MAX_BYTES: usize = 16 * 1024 * 1024;
990
991/// Spawn a child with piped stdout/stderr and read each pipe with a
992/// hard byte cap. Once the cap is hit the remaining bytes are drained
993/// to a sink (so the child can exit cleanly) and a debug log records
994/// the truncation. Returns the captured payload as lossy-UTF8 Strings
995/// alongside the child's exit status.
996fn run_with_bounded_output(
997    cmd: &mut Command,
998    alias: &str,
999) -> anyhow::Result<(ExitStatus, String, String)> {
1000    let mut child = cmd
1001        .spawn()
1002        .map_err(|e| anyhow::anyhow!("Failed to spawn ssh for '{}': {}", alias, e))?;
1003    let stdout = child.stdout.take();
1004    let stderr = child.stderr.take();
1005
1006    let alias_for_stdout = alias.to_string();
1007    let stdout_handle = std::thread::spawn(move || match stdout {
1008        Some(mut pipe) => {
1009            read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stdout, "stdout")
1010        }
1011        None => Vec::new(),
1012    });
1013    let alias_for_stderr = alias.to_string();
1014    let stderr_handle = std::thread::spawn(move || match stderr {
1015        Some(mut pipe) => {
1016            read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stderr, "stderr")
1017        }
1018        None => Vec::new(),
1019    });
1020
1021    // Join the drain threads BEFORE waiting on the child. The threads
1022    // own the pipe read-ends; when they finish (cap hit or EOF) the
1023    // reader handle drops and closes its side of the pipe, which
1024    // unblocks any pending write on the remote child. Waiting on the
1025    // child first would deadlock the moment stdout exceeded the kernel
1026    // pipe buffer (typically 64 KB on Linux): the child blocks on
1027    // write, the parent blocks on wait, neither thread runs.
1028    let stdout_bytes = stdout_handle.join().unwrap_or_default();
1029    let stderr_bytes = stderr_handle.join().unwrap_or_default();
1030    let status = child
1031        .wait()
1032        .map_err(|e| anyhow::anyhow!("ssh wait failed for '{}': {}", alias, e))?;
1033
1034    Ok((
1035        status,
1036        String::from_utf8_lossy(&stdout_bytes).to_string(),
1037        String::from_utf8_lossy(&stderr_bytes).to_string(),
1038    ))
1039}
1040
1041fn read_bounded<R: std::io::Read>(
1042    reader: &mut R,
1043    max: usize,
1044    alias: &str,
1045    stream: &str,
1046) -> Vec<u8> {
1047    let mut out = Vec::with_capacity(8 * 1024);
1048    let mut chunk = [0u8; 8 * 1024];
1049    loop {
1050        match reader.read(&mut chunk) {
1051            Ok(0) => break,
1052            Ok(n) => {
1053                if out.len() + n > max {
1054                    let allowed = max.saturating_sub(out.len());
1055                    out.extend_from_slice(&chunk[..allowed]);
1056                    log::warn!(
1057                        "[external] ssh {} for '{}' exceeded {} bytes; truncating remainder",
1058                        stream,
1059                        alias,
1060                        max
1061                    );
1062                    // Drain remaining bytes so the child can exit cleanly
1063                    // instead of blocking on a backpressured pipe.
1064                    let _ = std::io::copy(reader, &mut std::io::sink());
1065                    break;
1066                }
1067                out.extend_from_slice(&chunk[..n]);
1068            }
1069            Err(e) => {
1070                log::debug!("[external] ssh {stream} read error for '{alias}': {e}");
1071                break;
1072            }
1073        }
1074    }
1075    out
1076}
1077
1078/// Static, execution-free blast-radius analysis of a snippet command for the
1079/// Snippets detail IMPACT card. Implemented in [`crate::snippet_impact`]
1080/// (quote-aware segmenter + curated capability table); re-exported here so the
1081/// `crate::snippet::analyze_command` path stays stable.
1082pub use crate::snippet_impact::{Category, CommandImpact, Finding, Severity, analyze_command};
1083
1084/// Indices of snippets in `store` matching `query` by name, command or
1085/// description (case-insensitive). A `None` or empty query returns every index
1086/// in order. Shared by the host-list snippet picker and the Snippets tab.
1087pub fn filtered_indices(store: &SnippetStore, query: Option<&str>) -> Vec<usize> {
1088    match query {
1089        None | Some("") => (0..store.snippets.len()).collect(),
1090        Some(q) => store
1091            .snippets
1092            .iter()
1093            .enumerate()
1094            .filter(|(_, s)| {
1095                crate::app::contains_ci(&s.name, q)
1096                    || crate::app::contains_ci(&s.command, q)
1097                    || crate::app::contains_ci(&s.description, q)
1098            })
1099            .map(|(i, _)| i)
1100            .collect(),
1101    }
1102}
1103
1104#[cfg(test)]
1105#[path = "snippet_tests.rs"]
1106mod tests;