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