Skip to main content

purple_ssh/
snippet.rs

1use std::io;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use crate::fs_util;
6
7/// A saved command snippet.
8#[derive(Debug, Clone, PartialEq)]
9pub struct Snippet {
10    pub name: String,
11    pub command: String,
12    pub description: String,
13}
14
15/// Result of running a snippet on a host.
16pub struct SnippetResult {
17    pub status: ExitStatus,
18    pub stdout: String,
19    pub stderr: String,
20}
21
22/// Snippet storage backed by ~/.purple/snippets (INI-style).
23#[derive(Debug, Clone, Default)]
24pub struct SnippetStore {
25    pub snippets: Vec<Snippet>,
26    /// Override path for save(). None uses the default ~/.purple/snippets.
27    pub path_override: Option<PathBuf>,
28}
29
30fn config_path() -> Option<PathBuf> {
31    dirs::home_dir().map(|h| h.join(".purple/snippets"))
32}
33
34impl SnippetStore {
35    /// Load snippets from ~/.purple/snippets.
36    /// Returns empty store if file doesn't exist (normal first-use).
37    pub fn load() -> Self {
38        let path = match config_path() {
39            Some(p) => p,
40            None => return Self::default(),
41        };
42        let content = match std::fs::read_to_string(&path) {
43            Ok(c) => c,
44            Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
45            Err(e) => {
46                eprintln!("! Could not read {}: {}", path.display(), e);
47                return Self::default();
48            }
49        };
50        Self::parse(&content)
51    }
52
53    /// Parse INI-style snippet config.
54    pub fn parse(content: &str) -> Self {
55        let mut snippets = Vec::new();
56        let mut current: Option<Snippet> = None;
57
58        for line in content.lines() {
59            let trimmed = line.trim();
60            if trimmed.is_empty() || trimmed.starts_with('#') {
61                continue;
62            }
63            if trimmed.starts_with('[') && trimmed.ends_with(']') {
64                if let Some(snippet) = current.take() {
65                    if !snippet.command.is_empty()
66                        && !snippets.iter().any(|s: &Snippet| s.name == snippet.name)
67                    {
68                        snippets.push(snippet);
69                    }
70                }
71                let name = trimmed[1..trimmed.len() - 1].trim().to_string();
72                if snippets.iter().any(|s| s.name == name) {
73                    current = None;
74                    continue;
75                }
76                current = Some(Snippet {
77                    name,
78                    command: String::new(),
79                    description: String::new(),
80                });
81            } else if let Some(ref mut snippet) = current {
82                if let Some((key, value)) = trimmed.split_once('=') {
83                    let key = key.trim();
84                    // Trim whitespace around key but preserve value content
85                    // (only trim leading whitespace after '=', not trailing)
86                    let value = value.trim_start().to_string();
87                    match key {
88                        "command" => snippet.command = value,
89                        "description" => snippet.description = value,
90                        _ => {}
91                    }
92                }
93            }
94        }
95        if let Some(snippet) = current {
96            if !snippet.command.is_empty() && !snippets.iter().any(|s| s.name == snippet.name) {
97                snippets.push(snippet);
98            }
99        }
100        Self {
101            snippets,
102            path_override: None,
103        }
104    }
105
106    /// Save snippets to ~/.purple/snippets (atomic write, chmod 600).
107    pub fn save(&self) -> io::Result<()> {
108        let path = match &self.path_override {
109            Some(p) => p.clone(),
110            None => match config_path() {
111                Some(p) => p,
112                None => {
113                    return Err(io::Error::new(
114                        io::ErrorKind::NotFound,
115                        "Could not determine home directory",
116                    ));
117                }
118            },
119        };
120
121        let mut content = String::new();
122        for (i, snippet) in self.snippets.iter().enumerate() {
123            if i > 0 {
124                content.push('\n');
125            }
126            content.push_str(&format!("[{}]\n", snippet.name));
127            content.push_str(&format!("command={}\n", snippet.command));
128            if !snippet.description.is_empty() {
129                content.push_str(&format!("description={}\n", snippet.description));
130            }
131        }
132
133        fs_util::atomic_write(&path, content.as_bytes())
134    }
135
136    /// Get a snippet by name.
137    pub fn get(&self, name: &str) -> Option<&Snippet> {
138        self.snippets.iter().find(|s| s.name == name)
139    }
140
141    /// Add or replace a snippet.
142    pub fn set(&mut self, snippet: Snippet) {
143        if let Some(existing) = self.snippets.iter_mut().find(|s| s.name == snippet.name) {
144            *existing = snippet;
145        } else {
146            self.snippets.push(snippet);
147        }
148    }
149
150    /// Remove a snippet by name.
151    pub fn remove(&mut self, name: &str) {
152        self.snippets.retain(|s| s.name != name);
153    }
154}
155
156/// Validate a snippet name: non-empty, no leading/trailing whitespace,
157/// no `#`, no `[`, no `]`, no control characters.
158pub fn validate_name(name: &str) -> Result<(), String> {
159    if name.trim().is_empty() {
160        return Err("Snippet name cannot be empty.".to_string());
161    }
162    if name != name.trim() {
163        return Err("Snippet name cannot have leading or trailing whitespace.".to_string());
164    }
165    if name.contains('#') || name.contains('[') || name.contains(']') {
166        return Err("Snippet name cannot contain #, [ or ].".to_string());
167    }
168    if name.contains(|c: char| c.is_control()) {
169        return Err("Snippet name cannot contain control characters.".to_string());
170    }
171    Ok(())
172}
173
174/// Validate a snippet command: non-empty, no control characters (except tab).
175pub fn validate_command(command: &str) -> Result<(), String> {
176    if command.trim().is_empty() {
177        return Err("Command cannot be empty.".to_string());
178    }
179    if command.contains(|c: char| c.is_control() && c != '\t') {
180        return Err("Command cannot contain control characters.".to_string());
181    }
182    Ok(())
183}
184
185// =========================================================================
186// Parameter support
187// =========================================================================
188
189/// A parameter found in a snippet command template.
190#[derive(Debug, Clone, PartialEq)]
191pub struct SnippetParam {
192    pub name: String,
193    pub default: Option<String>,
194}
195
196/// Shell-escape a string with single quotes (POSIX).
197/// Internal single quotes are escaped as `'\''`.
198pub fn shell_escape(s: &str) -> String {
199    format!("'{}'", s.replace('\'', "'\\''"))
200}
201
202/// Parse `{{name}}` and `{{name:default}}` from a command string.
203/// Returns params in order of first appearance, deduplicated. Max 20 params.
204pub fn parse_params(command: &str) -> Vec<SnippetParam> {
205    let mut params = Vec::new();
206    let mut seen = std::collections::HashSet::new();
207    let bytes = command.as_bytes();
208    let len = bytes.len();
209    let mut i = 0;
210    while i + 3 < len {
211        if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
212            if let Some(end) = command[i + 2..].find("}}") {
213                let inner = &command[i + 2..i + 2 + end];
214                let (name, default) = if let Some((n, d)) = inner.split_once(':') {
215                    (n.to_string(), Some(d.to_string()))
216                } else {
217                    (inner.to_string(), None)
218                };
219                if validate_param_name(&name).is_ok() && !seen.contains(&name) && params.len() < 20
220                {
221                    seen.insert(name.clone());
222                    params.push(SnippetParam { name, default });
223                }
224                i = i + 2 + end + 2;
225                continue;
226            }
227        }
228        i += 1;
229    }
230    params
231}
232
233/// Validate a parameter name: non-empty, alphanumeric/underscore/hyphen only.
234/// Rejects `{`, `}`, `'`, whitespace and control chars.
235pub fn validate_param_name(name: &str) -> Result<(), String> {
236    if name.is_empty() {
237        return Err("Parameter name cannot be empty.".to_string());
238    }
239    if !name
240        .chars()
241        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
242    {
243        return Err(format!(
244            "Parameter name '{}' contains invalid characters.",
245            name
246        ));
247    }
248    Ok(())
249}
250
251/// Substitute parameters into a command template (single-pass).
252/// All values (user-provided and defaults) are shell-escaped.
253pub fn substitute_params(
254    command: &str,
255    values: &std::collections::HashMap<String, String>,
256) -> String {
257    let mut result = String::with_capacity(command.len());
258    let bytes = command.as_bytes();
259    let len = bytes.len();
260    let mut i = 0;
261    while i < len {
262        if i + 3 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
263            if let Some(end) = command[i + 2..].find("}}") {
264                let inner = &command[i + 2..i + 2 + end];
265                let (name, default) = if let Some((n, d)) = inner.split_once(':') {
266                    (n, Some(d))
267                } else {
268                    (inner, None)
269                };
270                let value = values
271                    .get(name)
272                    .filter(|v| !v.is_empty())
273                    .map(|v| v.as_str())
274                    .or(default)
275                    .unwrap_or("");
276                result.push_str(&shell_escape(value));
277                i = i + 2 + end + 2;
278                continue;
279            }
280        }
281        // Properly decode UTF-8 character (not byte-level cast)
282        let ch = command[i..].chars().next().unwrap();
283        result.push(ch);
284        i += ch.len_utf8();
285    }
286    result
287}
288
289// =========================================================================
290// Output sanitization
291// =========================================================================
292
293/// Strip ANSI escape sequences and C1 control codes from output.
294/// Handles CSI, OSC, DCS, SOS, PM and APC sequences plus the C1 range 0x80-0x9F.
295pub fn sanitize_output(input: &str) -> String {
296    let mut out = String::with_capacity(input.len());
297    let mut chars = input.chars().peekable();
298    while let Some(c) = chars.next() {
299        match c {
300            '\x1b' => {
301                match chars.peek() {
302                    Some('[') => {
303                        chars.next();
304                        // CSI: consume until 0x40-0x7E
305                        while let Some(&ch) = chars.peek() {
306                            chars.next();
307                            if ('\x40'..='\x7e').contains(&ch) {
308                                break;
309                            }
310                        }
311                    }
312                    Some(']') | Some('P') | Some('X') | Some('^') | Some('_') => {
313                        chars.next();
314                        // OSC/DCS/SOS/PM/APC: consume until ST (ESC\) or BEL
315                        consume_until_st(&mut chars);
316                    }
317                    _ => {
318                        // Single ESC + one char
319                        chars.next();
320                    }
321                }
322            }
323            c if ('\u{0080}'..='\u{009F}').contains(&c) => {
324                // C1 control codes: skip
325            }
326            c if c.is_control() && c != '\n' && c != '\t' => {
327                // Other control chars (except newline/tab): skip
328            }
329            _ => out.push(c),
330        }
331    }
332    out
333}
334
335/// Consume chars until String Terminator (ESC\) or BEL (\x07).
336fn consume_until_st(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
337    while let Some(&ch) = chars.peek() {
338        if ch == '\x07' {
339            chars.next();
340            break;
341        }
342        if ch == '\x1b' {
343            chars.next();
344            if chars.peek() == Some(&'\\') {
345                chars.next();
346            }
347            break;
348        }
349        chars.next();
350    }
351}
352
353// =========================================================================
354// Background snippet execution
355// =========================================================================
356
357/// Maximum lines stored per host. Reader continues draining beyond this
358/// to prevent child from blocking on a full pipe buffer.
359const MAX_OUTPUT_LINES: usize = 10_000;
360
361/// Events emitted during background snippet execution.
362/// These are mapped to AppEvent by the caller in main.rs.
363pub enum SnippetEvent {
364    HostDone {
365        run_id: u64,
366        alias: String,
367        stdout: String,
368        stderr: String,
369        exit_code: Option<i32>,
370    },
371    Progress {
372        run_id: u64,
373        completed: usize,
374        total: usize,
375    },
376    AllDone {
377        run_id: u64,
378    },
379}
380
381/// RAII guard that kills the process group on drop.
382/// Uses SIGTERM first, then escalates to SIGKILL after a brief wait.
383pub struct ChildGuard {
384    inner: std::sync::Mutex<Option<std::process::Child>>,
385    pgid: i32,
386}
387
388impl ChildGuard {
389    fn new(child: std::process::Child) -> Self {
390        let pgid = child.id() as i32;
391        Self {
392            inner: std::sync::Mutex::new(Some(child)),
393            pgid,
394        }
395    }
396}
397
398impl Drop for ChildGuard {
399    fn drop(&mut self) {
400        let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
401        if let Some(ref mut child) = *lock {
402            // Already exited? Skip kill entirely (PID may be recycled).
403            if let Ok(Some(_)) = child.try_wait() {
404                return;
405            }
406            // SIGTERM the process group
407            #[cfg(unix)]
408            unsafe {
409                libc::kill(-self.pgid, libc::SIGTERM);
410            }
411            // Poll for up to 500ms
412            let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
413            loop {
414                if let Ok(Some(_)) = child.try_wait() {
415                    return;
416                }
417                if std::time::Instant::now() >= deadline {
418                    break;
419                }
420                std::thread::sleep(std::time::Duration::from_millis(50));
421            }
422            // Escalate to SIGKILL on the process group
423            #[cfg(unix)]
424            unsafe {
425                libc::kill(-self.pgid, libc::SIGKILL);
426            }
427            // Fallback: direct kill in case setpgid failed in pre_exec
428            let _ = child.kill();
429            let _ = child.wait();
430        }
431    }
432}
433
434/// Read lines from a pipe. Stores up to `MAX_OUTPUT_LINES` but continues
435/// draining the pipe after that to prevent the child from blocking.
436fn read_pipe_capped<R: io::Read>(reader: R) -> String {
437    use io::BufRead;
438    let mut reader = io::BufReader::new(reader);
439    let mut output = String::new();
440    let mut line_count = 0;
441    let mut capped = false;
442    let mut buf = Vec::new();
443    loop {
444        buf.clear();
445        match reader.read_until(b'\n', &mut buf) {
446            Ok(0) => break, // EOF
447            Ok(_) => {
448                if !capped {
449                    if line_count < MAX_OUTPUT_LINES {
450                        if line_count > 0 {
451                            output.push('\n');
452                        }
453                        // Strip trailing newline (and \r for CRLF)
454                        if buf.last() == Some(&b'\n') {
455                            buf.pop();
456                            if buf.last() == Some(&b'\r') {
457                                buf.pop();
458                            }
459                        }
460                        // Lossy conversion handles non-UTF-8 output
461                        output.push_str(&String::from_utf8_lossy(&buf));
462                        line_count += 1;
463                    } else {
464                        output.push_str("\n[Output truncated at 10,000 lines]");
465                        capped = true;
466                    }
467                }
468                // If capped, keep reading but discard to drain the pipe
469            }
470            Err(_) => break,
471        }
472    }
473    output
474}
475
476/// Build the base SSH command with shared options for snippet execution.
477/// Sets -F, ConnectTimeout, ControlMaster/ControlPath and ClearAllForwardings.
478/// Also configures askpass and Bitwarden session env vars.
479fn base_ssh_command(
480    alias: &str,
481    config_path: &Path,
482    command: &str,
483    askpass: Option<&str>,
484    bw_session: Option<&str>,
485    has_active_tunnel: bool,
486) -> Command {
487    let mut cmd = Command::new("ssh");
488    cmd.arg("-F")
489        .arg(config_path)
490        .arg("-o")
491        .arg("ConnectTimeout=10")
492        .arg("-o")
493        .arg("ControlMaster=no")
494        .arg("-o")
495        .arg("ControlPath=none");
496
497    if has_active_tunnel {
498        cmd.arg("-o").arg("ClearAllForwardings=yes");
499    }
500
501    cmd.arg("--").arg(alias).arg(command);
502
503    if askpass.is_some() {
504        let exe = std::env::current_exe()
505            .ok()
506            .map(|p| p.to_string_lossy().to_string())
507            .or_else(|| std::env::args().next())
508            .unwrap_or_else(|| "purple".to_string());
509        cmd.env("SSH_ASKPASS", &exe)
510            .env("SSH_ASKPASS_REQUIRE", "prefer")
511            .env("PURPLE_ASKPASS_MODE", "1")
512            .env("PURPLE_HOST_ALIAS", alias)
513            .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
514    }
515
516    if let Some(token) = bw_session {
517        cmd.env("BW_SESSION", token);
518    }
519
520    cmd
521}
522
523/// Build the SSH Command for a snippet execution with piped I/O.
524fn build_snippet_command(
525    alias: &str,
526    config_path: &Path,
527    command: &str,
528    askpass: Option<&str>,
529    bw_session: Option<&str>,
530    has_active_tunnel: bool,
531) -> Command {
532    let mut cmd = base_ssh_command(
533        alias,
534        config_path,
535        command,
536        askpass,
537        bw_session,
538        has_active_tunnel,
539    );
540    cmd.stdin(Stdio::null())
541        .stdout(Stdio::piped())
542        .stderr(Stdio::piped());
543
544    // Isolate child into its own process group so we can kill the
545    // entire tree without affecting purple itself.
546    #[cfg(unix)]
547    unsafe {
548        use std::os::unix::process::CommandExt;
549        cmd.pre_exec(|| {
550            libc::setpgid(0, 0);
551            Ok(())
552        });
553    }
554
555    cmd
556}
557
558/// Execute a single host: spawn SSH, read output, wait, send result.
559#[allow(clippy::too_many_arguments)]
560fn execute_host(
561    run_id: u64,
562    alias: &str,
563    config_path: &Path,
564    command: &str,
565    askpass: Option<&str>,
566    bw_session: Option<&str>,
567    has_active_tunnel: bool,
568    tx: &std::sync::mpsc::Sender<SnippetEvent>,
569) -> Option<std::sync::Arc<ChildGuard>> {
570    let mut cmd = build_snippet_command(
571        alias,
572        config_path,
573        command,
574        askpass,
575        bw_session,
576        has_active_tunnel,
577    );
578
579    match cmd.spawn() {
580        Ok(child) => {
581            let guard = std::sync::Arc::new(ChildGuard::new(child));
582
583            // Take stdout/stderr BEFORE wait to avoid pipe deadlock
584            let stdout_pipe = {
585                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
586                lock.as_mut().and_then(|c| c.stdout.take())
587            };
588            let stderr_pipe = {
589                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
590                lock.as_mut().and_then(|c| c.stderr.take())
591            };
592
593            // Spawn reader threads
594            let stdout_handle = std::thread::spawn(move || match stdout_pipe {
595                Some(pipe) => read_pipe_capped(pipe),
596                None => String::new(),
597            });
598            let stderr_handle = std::thread::spawn(move || match stderr_pipe {
599                Some(pipe) => read_pipe_capped(pipe),
600                None => String::new(),
601            });
602
603            // Join readers BEFORE wait to guarantee all output is received
604            let stdout_text = stdout_handle.join().unwrap_or_default();
605            let stderr_text = stderr_handle.join().unwrap_or_default();
606
607            // Now wait for the child to exit, then take it out of the
608            // guard so Drop won't kill a potentially recycled PID.
609            let exit_code = {
610                let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
611                let status = lock.as_mut().and_then(|c| c.wait().ok());
612                let _ = lock.take(); // Prevent ChildGuard::drop from killing recycled PID
613                status.and_then(|s| {
614                    #[cfg(unix)]
615                    {
616                        use std::os::unix::process::ExitStatusExt;
617                        s.code().or_else(|| s.signal().map(|sig| 128 + sig))
618                    }
619                    #[cfg(not(unix))]
620                    {
621                        s.code()
622                    }
623                })
624            };
625
626            let _ = tx.send(SnippetEvent::HostDone {
627                run_id,
628                alias: alias.to_string(),
629                stdout: sanitize_output(&stdout_text),
630                stderr: sanitize_output(&stderr_text),
631                exit_code,
632            });
633
634            Some(guard)
635        }
636        Err(e) => {
637            let _ = tx.send(SnippetEvent::HostDone {
638                run_id,
639                alias: alias.to_string(),
640                stdout: String::new(),
641                stderr: format!("Failed to launch ssh: {}", e),
642                exit_code: None,
643            });
644            None
645        }
646    }
647}
648
649/// Spawn background snippet execution on multiple hosts.
650/// The coordinator thread drives sequential or parallel host iteration.
651#[allow(clippy::too_many_arguments)]
652pub fn spawn_snippet_execution(
653    run_id: u64,
654    askpass_map: Vec<(String, Option<String>)>,
655    config_path: PathBuf,
656    command: String,
657    bw_session: Option<String>,
658    tunnel_aliases: std::collections::HashSet<String>,
659    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
660    tx: std::sync::mpsc::Sender<SnippetEvent>,
661    parallel: bool,
662) {
663    let total = askpass_map.len();
664    let max_concurrent: usize = 20;
665
666    std::thread::Builder::new()
667        .name("snippet-coordinator".into())
668        .spawn(move || {
669            let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
670                std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
671
672            if parallel && total > 1 {
673                // Slot-based semaphore for concurrency limiting
674                let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
675                for _ in 0..max_concurrent.min(total) {
676                    let _ = slot_tx.send(());
677                }
678
679                let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
680                let mut worker_handles = Vec::new();
681
682                for (alias, askpass) in askpass_map {
683                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
684                        break;
685                    }
686
687                    // Wait for a slot, checking cancel periodically
688                    loop {
689                        match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
690                            Ok(()) => break,
691                            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
692                                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
693                                    break;
694                                }
695                            }
696                            Err(_) => break, // channel closed
697                        }
698                    }
699
700                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
701                        break;
702                    }
703
704                    let config_path = config_path.clone();
705                    let command = command.clone();
706                    let bw_session = bw_session.clone();
707                    let has_tunnel = tunnel_aliases.contains(&alias);
708                    let tx = tx.clone();
709                    let slot_tx = slot_tx.clone();
710                    let guards = guards.clone();
711                    let completed = completed.clone();
712                    let total = total;
713
714                    let handle = std::thread::spawn(move || {
715                        // RAII guard: release semaphore slot even on panic
716                        struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
717                        impl Drop for SlotRelease {
718                            fn drop(&mut self) {
719                                if let Some(tx) = self.0.take() {
720                                    let _ = tx.send(());
721                                }
722                            }
723                        }
724                        let _slot = SlotRelease(Some(slot_tx));
725
726                        let guard = execute_host(
727                            run_id,
728                            &alias,
729                            &config_path,
730                            &command,
731                            askpass.as_deref(),
732                            bw_session.as_deref(),
733                            has_tunnel,
734                            &tx,
735                        );
736
737                        // Insert guard BEFORE checking cancel so it can be cleaned up
738                        if let Some(g) = guard {
739                            guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
740                        }
741
742                        let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
743                        let _ = tx.send(SnippetEvent::Progress {
744                            run_id,
745                            completed: c,
746                            total,
747                        });
748                        // _slot dropped here, releasing semaphore
749                    });
750                    worker_handles.push(handle);
751                }
752
753                // Wait for all workers to finish
754                for handle in worker_handles {
755                    let _ = handle.join();
756                }
757            } else {
758                // Sequential execution
759                for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
760                    if cancel.load(std::sync::atomic::Ordering::Relaxed) {
761                        break;
762                    }
763
764                    let has_tunnel = tunnel_aliases.contains(&alias);
765                    let guard = execute_host(
766                        run_id,
767                        &alias,
768                        &config_path,
769                        &command,
770                        askpass.as_deref(),
771                        bw_session.as_deref(),
772                        has_tunnel,
773                        &tx,
774                    );
775
776                    if let Some(g) = guard {
777                        guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
778                    }
779
780                    let _ = tx.send(SnippetEvent::Progress {
781                        run_id,
782                        completed: i + 1,
783                        total,
784                    });
785                }
786            }
787
788            let _ = tx.send(SnippetEvent::AllDone { run_id });
789            // Guards dropped here, cleaning up any remaining children
790        })
791        .expect("failed to spawn snippet coordinator");
792}
793
794/// Run a snippet on a single host via SSH.
795/// When `capture` is true, stdout/stderr are piped and returned in the result.
796/// When `capture` is false, stdout/stderr are inherited (streamed to terminal
797/// in real-time) and the returned strings are empty.
798pub fn run_snippet(
799    alias: &str,
800    config_path: &Path,
801    command: &str,
802    askpass: Option<&str>,
803    bw_session: Option<&str>,
804    capture: bool,
805    has_active_tunnel: bool,
806) -> anyhow::Result<SnippetResult> {
807    let mut cmd = base_ssh_command(
808        alias,
809        config_path,
810        command,
811        askpass,
812        bw_session,
813        has_active_tunnel,
814    );
815    cmd.stdin(Stdio::inherit());
816
817    if capture {
818        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
819    } else {
820        cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
821    }
822
823    if capture {
824        let output = cmd
825            .output()
826            .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
827
828        Ok(SnippetResult {
829            status: output.status,
830            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
831            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
832        })
833    } else {
834        let status = cmd
835            .status()
836            .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
837
838        Ok(SnippetResult {
839            status,
840            stdout: String::new(),
841            stderr: String::new(),
842        })
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    // =========================================================================
851    // Parse
852    // =========================================================================
853
854    #[test]
855    fn test_parse_empty() {
856        let store = SnippetStore::parse("");
857        assert!(store.snippets.is_empty());
858    }
859
860    #[test]
861    fn test_parse_single_snippet() {
862        let content = "\
863[check-disk]
864command=df -h
865description=Check disk usage
866";
867        let store = SnippetStore::parse(content);
868        assert_eq!(store.snippets.len(), 1);
869        let s = &store.snippets[0];
870        assert_eq!(s.name, "check-disk");
871        assert_eq!(s.command, "df -h");
872        assert_eq!(s.description, "Check disk usage");
873    }
874
875    #[test]
876    fn test_parse_multiple_snippets() {
877        let content = "\
878[check-disk]
879command=df -h
880
881[uptime]
882command=uptime
883description=Check server uptime
884";
885        let store = SnippetStore::parse(content);
886        assert_eq!(store.snippets.len(), 2);
887        assert_eq!(store.snippets[0].name, "check-disk");
888        assert_eq!(store.snippets[1].name, "uptime");
889    }
890
891    #[test]
892    fn test_parse_comments_and_blanks() {
893        let content = "\
894# Snippet config
895
896[check-disk]
897# Main command
898command=df -h
899";
900        let store = SnippetStore::parse(content);
901        assert_eq!(store.snippets.len(), 1);
902        assert_eq!(store.snippets[0].command, "df -h");
903    }
904
905    #[test]
906    fn test_parse_duplicate_sections_first_wins() {
907        let content = "\
908[check-disk]
909command=df -h
910
911[check-disk]
912command=du -sh *
913";
914        let store = SnippetStore::parse(content);
915        assert_eq!(store.snippets.len(), 1);
916        assert_eq!(store.snippets[0].command, "df -h");
917    }
918
919    #[test]
920    fn test_parse_snippet_without_command_skipped() {
921        let content = "\
922[empty]
923description=No command here
924
925[valid]
926command=ls -la
927";
928        let store = SnippetStore::parse(content);
929        assert_eq!(store.snippets.len(), 1);
930        assert_eq!(store.snippets[0].name, "valid");
931    }
932
933    #[test]
934    fn test_parse_unknown_keys_ignored() {
935        let content = "\
936[check-disk]
937command=df -h
938unknown=value
939foo=bar
940";
941        let store = SnippetStore::parse(content);
942        assert_eq!(store.snippets.len(), 1);
943        assert_eq!(store.snippets[0].command, "df -h");
944    }
945
946    #[test]
947    fn test_parse_whitespace_in_section_name() {
948        let content = "[ check-disk ]\ncommand=df -h\n";
949        let store = SnippetStore::parse(content);
950        assert_eq!(store.snippets[0].name, "check-disk");
951    }
952
953    #[test]
954    fn test_parse_whitespace_around_key_value() {
955        let content = "[check-disk]\n  command  =  df -h  \n";
956        let store = SnippetStore::parse(content);
957        assert_eq!(store.snippets[0].command, "df -h");
958    }
959
960    #[test]
961    fn test_parse_command_with_equals() {
962        let content = "[env-check]\ncommand=env | grep HOME=\n";
963        let store = SnippetStore::parse(content);
964        assert_eq!(store.snippets[0].command, "env | grep HOME=");
965    }
966
967    #[test]
968    fn test_parse_line_without_equals_ignored() {
969        let content = "[check]\ncommand=ls\ngarbage_line\n";
970        let store = SnippetStore::parse(content);
971        assert_eq!(store.snippets[0].command, "ls");
972    }
973
974    // =========================================================================
975    // Get / Set / Remove
976    // =========================================================================
977
978    #[test]
979    fn test_get_found() {
980        let store = SnippetStore::parse("[check]\ncommand=ls\n");
981        assert!(store.get("check").is_some());
982    }
983
984    #[test]
985    fn test_get_not_found() {
986        let store = SnippetStore::parse("");
987        assert!(store.get("nope").is_none());
988    }
989
990    #[test]
991    fn test_set_adds_new() {
992        let mut store = SnippetStore::default();
993        store.set(Snippet {
994            name: "check".to_string(),
995            command: "ls".to_string(),
996            description: String::new(),
997        });
998        assert_eq!(store.snippets.len(), 1);
999    }
1000
1001    #[test]
1002    fn test_set_replaces_existing() {
1003        let mut store = SnippetStore::parse("[check]\ncommand=ls\n");
1004        store.set(Snippet {
1005            name: "check".to_string(),
1006            command: "df -h".to_string(),
1007            description: String::new(),
1008        });
1009        assert_eq!(store.snippets.len(), 1);
1010        assert_eq!(store.snippets[0].command, "df -h");
1011    }
1012
1013    #[test]
1014    fn test_remove() {
1015        let mut store = SnippetStore::parse("[check]\ncommand=ls\n[uptime]\ncommand=uptime\n");
1016        store.remove("check");
1017        assert_eq!(store.snippets.len(), 1);
1018        assert_eq!(store.snippets[0].name, "uptime");
1019    }
1020
1021    #[test]
1022    fn test_remove_nonexistent_noop() {
1023        let mut store = SnippetStore::parse("[check]\ncommand=ls\n");
1024        store.remove("nope");
1025        assert_eq!(store.snippets.len(), 1);
1026    }
1027
1028    // =========================================================================
1029    // Validate name
1030    // =========================================================================
1031
1032    #[test]
1033    fn test_validate_name_valid() {
1034        assert!(validate_name("check-disk").is_ok());
1035        assert!(validate_name("restart_nginx").is_ok());
1036        assert!(validate_name("a").is_ok());
1037    }
1038
1039    #[test]
1040    fn test_validate_name_empty() {
1041        assert!(validate_name("").is_err());
1042    }
1043
1044    #[test]
1045    fn test_validate_name_whitespace() {
1046        assert!(validate_name("check disk").is_ok());
1047        assert!(validate_name("check\tdisk").is_err()); // tab is a control character
1048        assert!(validate_name("  ").is_err()); // only whitespace
1049        assert!(validate_name(" leading").is_err()); // leading whitespace
1050        assert!(validate_name("trailing ").is_err()); // trailing whitespace
1051    }
1052
1053    #[test]
1054    fn test_validate_name_special_chars() {
1055        assert!(validate_name("check#disk").is_err());
1056        assert!(validate_name("[check]").is_err());
1057    }
1058
1059    #[test]
1060    fn test_validate_name_control_chars() {
1061        assert!(validate_name("check\x00disk").is_err());
1062    }
1063
1064    // =========================================================================
1065    // Validate command
1066    // =========================================================================
1067
1068    #[test]
1069    fn test_validate_command_valid() {
1070        assert!(validate_command("df -h").is_ok());
1071        assert!(validate_command("cat /etc/hosts | grep localhost").is_ok());
1072        assert!(validate_command("echo 'hello\tworld'").is_ok()); // tab allowed
1073    }
1074
1075    #[test]
1076    fn test_validate_command_empty() {
1077        assert!(validate_command("").is_err());
1078    }
1079
1080    #[test]
1081    fn test_validate_command_whitespace_only() {
1082        assert!(validate_command("   ").is_err());
1083        assert!(validate_command(" \t ").is_err());
1084    }
1085
1086    #[test]
1087    fn test_validate_command_control_chars() {
1088        assert!(validate_command("ls\x00-la").is_err());
1089    }
1090
1091    // =========================================================================
1092    // Save / roundtrip
1093    // =========================================================================
1094
1095    #[test]
1096    fn test_save_roundtrip() {
1097        let mut store = SnippetStore::default();
1098        store.set(Snippet {
1099            name: "check-disk".to_string(),
1100            command: "df -h".to_string(),
1101            description: "Check disk usage".to_string(),
1102        });
1103        store.set(Snippet {
1104            name: "uptime".to_string(),
1105            command: "uptime".to_string(),
1106            description: String::new(),
1107        });
1108
1109        // Serialize
1110        let mut content = String::new();
1111        for (i, snippet) in store.snippets.iter().enumerate() {
1112            if i > 0 {
1113                content.push('\n');
1114            }
1115            content.push_str(&format!("[{}]\n", snippet.name));
1116            content.push_str(&format!("command={}\n", snippet.command));
1117            if !snippet.description.is_empty() {
1118                content.push_str(&format!("description={}\n", snippet.description));
1119            }
1120        }
1121
1122        // Re-parse
1123        let reparsed = SnippetStore::parse(&content);
1124        assert_eq!(reparsed.snippets.len(), 2);
1125        assert_eq!(reparsed.snippets[0].name, "check-disk");
1126        assert_eq!(reparsed.snippets[0].command, "df -h");
1127        assert_eq!(reparsed.snippets[0].description, "Check disk usage");
1128        assert_eq!(reparsed.snippets[1].name, "uptime");
1129        assert_eq!(reparsed.snippets[1].command, "uptime");
1130        assert!(reparsed.snippets[1].description.is_empty());
1131    }
1132
1133    #[test]
1134    fn test_save_to_temp_file() {
1135        let dir = std::env::temp_dir().join(format!("purple_snippet_test_{}", std::process::id()));
1136        let _ = std::fs::create_dir_all(&dir);
1137        let path = dir.join("snippets");
1138
1139        let mut store = SnippetStore {
1140            path_override: Some(path.clone()),
1141            ..Default::default()
1142        };
1143        store.set(Snippet {
1144            name: "test".to_string(),
1145            command: "echo hello".to_string(),
1146            description: "Test snippet".to_string(),
1147        });
1148        store.save().unwrap();
1149
1150        // Read back
1151        let content = std::fs::read_to_string(&path).unwrap();
1152        let reloaded = SnippetStore::parse(&content);
1153        assert_eq!(reloaded.snippets.len(), 1);
1154        assert_eq!(reloaded.snippets[0].name, "test");
1155        assert_eq!(reloaded.snippets[0].command, "echo hello");
1156
1157        // Cleanup
1158        let _ = std::fs::remove_dir_all(&dir);
1159    }
1160
1161    // =========================================================================
1162    // Edge cases
1163    // =========================================================================
1164
1165    #[test]
1166    fn test_set_multiple_then_remove_all() {
1167        let mut store = SnippetStore::default();
1168        for name in ["a", "b", "c"] {
1169            store.set(Snippet {
1170                name: name.to_string(),
1171                command: "cmd".to_string(),
1172                description: String::new(),
1173            });
1174        }
1175        assert_eq!(store.snippets.len(), 3);
1176        store.remove("a");
1177        store.remove("b");
1178        store.remove("c");
1179        assert!(store.snippets.is_empty());
1180    }
1181
1182    #[test]
1183    fn test_snippet_with_complex_command() {
1184        let content = "[complex]\ncommand=for i in $(seq 1 5); do echo $i; done\n";
1185        let store = SnippetStore::parse(content);
1186        assert_eq!(
1187            store.snippets[0].command,
1188            "for i in $(seq 1 5); do echo $i; done"
1189        );
1190    }
1191
1192    #[test]
1193    fn test_snippet_command_with_pipes_and_redirects() {
1194        let content = "[logs]\ncommand=tail -100 /var/log/syslog | grep error | head -20\n";
1195        let store = SnippetStore::parse(content);
1196        assert_eq!(
1197            store.snippets[0].command,
1198            "tail -100 /var/log/syslog | grep error | head -20"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_description_optional() {
1204        let content = "[check]\ncommand=ls\n";
1205        let store = SnippetStore::parse(content);
1206        assert!(store.snippets[0].description.is_empty());
1207    }
1208
1209    #[test]
1210    fn test_description_with_equals() {
1211        let content = "[env]\ncommand=env\ndescription=Check HOME= and PATH= vars\n";
1212        let store = SnippetStore::parse(content);
1213        assert_eq!(store.snippets[0].description, "Check HOME= and PATH= vars");
1214    }
1215
1216    #[test]
1217    fn test_name_with_equals_roundtrip() {
1218        let mut store = SnippetStore::default();
1219        store.set(Snippet {
1220            name: "check=disk".to_string(),
1221            command: "df -h".to_string(),
1222            description: String::new(),
1223        });
1224
1225        let mut content = String::new();
1226        for (i, snippet) in store.snippets.iter().enumerate() {
1227            if i > 0 {
1228                content.push('\n');
1229            }
1230            content.push_str(&format!("[{}]\n", snippet.name));
1231            content.push_str(&format!("command={}\n", snippet.command));
1232            if !snippet.description.is_empty() {
1233                content.push_str(&format!("description={}\n", snippet.description));
1234            }
1235        }
1236
1237        let reparsed = SnippetStore::parse(&content);
1238        assert_eq!(reparsed.snippets.len(), 1);
1239        assert_eq!(reparsed.snippets[0].name, "check=disk");
1240    }
1241
1242    #[test]
1243    fn test_validate_name_with_equals() {
1244        assert!(validate_name("check=disk").is_ok());
1245    }
1246
1247    #[test]
1248    fn test_parse_only_comments_and_blanks() {
1249        let content = "# comment\n\n# another\n";
1250        let store = SnippetStore::parse(content);
1251        assert!(store.snippets.is_empty());
1252    }
1253
1254    #[test]
1255    fn test_parse_section_without_close_bracket() {
1256        let content = "[incomplete\ncommand=ls\n";
1257        let store = SnippetStore::parse(content);
1258        assert!(store.snippets.is_empty());
1259    }
1260
1261    #[test]
1262    fn test_parse_trailing_content_after_last_section() {
1263        let content = "[check]\ncommand=ls\n";
1264        let store = SnippetStore::parse(content);
1265        assert_eq!(store.snippets.len(), 1);
1266        assert_eq!(store.snippets[0].command, "ls");
1267    }
1268
1269    #[test]
1270    fn test_set_overwrite_preserves_order() {
1271        let mut store = SnippetStore::default();
1272        store.set(Snippet {
1273            name: "a".into(),
1274            command: "1".into(),
1275            description: String::new(),
1276        });
1277        store.set(Snippet {
1278            name: "b".into(),
1279            command: "2".into(),
1280            description: String::new(),
1281        });
1282        store.set(Snippet {
1283            name: "c".into(),
1284            command: "3".into(),
1285            description: String::new(),
1286        });
1287        store.set(Snippet {
1288            name: "b".into(),
1289            command: "updated".into(),
1290            description: String::new(),
1291        });
1292        assert_eq!(store.snippets.len(), 3);
1293        assert_eq!(store.snippets[0].name, "a");
1294        assert_eq!(store.snippets[1].name, "b");
1295        assert_eq!(store.snippets[1].command, "updated");
1296        assert_eq!(store.snippets[2].name, "c");
1297    }
1298
1299    #[test]
1300    fn test_validate_command_with_tab() {
1301        assert!(validate_command("echo\thello").is_ok());
1302    }
1303
1304    #[test]
1305    fn test_validate_command_with_newline() {
1306        assert!(validate_command("echo\nhello").is_err());
1307    }
1308
1309    #[test]
1310    fn test_validate_name_newline() {
1311        assert!(validate_name("check\ndisk").is_err());
1312    }
1313
1314    // =========================================================================
1315    // shell_escape
1316    // =========================================================================
1317
1318    #[test]
1319    fn test_shell_escape_simple() {
1320        assert_eq!(shell_escape("hello"), "'hello'");
1321    }
1322
1323    #[test]
1324    fn test_shell_escape_with_single_quote() {
1325        assert_eq!(shell_escape("it's"), "'it'\\''s'");
1326    }
1327
1328    #[test]
1329    fn test_shell_escape_with_spaces() {
1330        assert_eq!(shell_escape("hello world"), "'hello world'");
1331    }
1332
1333    #[test]
1334    fn test_shell_escape_with_semicolon() {
1335        assert_eq!(shell_escape("; rm -rf /"), "'; rm -rf /'");
1336    }
1337
1338    #[test]
1339    fn test_shell_escape_with_dollar() {
1340        assert_eq!(shell_escape("$(whoami)"), "'$(whoami)'");
1341    }
1342
1343    #[test]
1344    fn test_shell_escape_empty() {
1345        assert_eq!(shell_escape(""), "''");
1346    }
1347
1348    // =========================================================================
1349    // parse_params
1350    // =========================================================================
1351
1352    #[test]
1353    fn test_parse_params_none() {
1354        assert!(parse_params("df -h").is_empty());
1355    }
1356
1357    #[test]
1358    fn test_parse_params_single() {
1359        let params = parse_params("df -h {{path}}");
1360        assert_eq!(params.len(), 1);
1361        assert_eq!(params[0].name, "path");
1362        assert_eq!(params[0].default, None);
1363    }
1364
1365    #[test]
1366    fn test_parse_params_with_default() {
1367        let params = parse_params("df -h {{path:/var/log}}");
1368        assert_eq!(params.len(), 1);
1369        assert_eq!(params[0].name, "path");
1370        assert_eq!(params[0].default, Some("/var/log".to_string()));
1371    }
1372
1373    #[test]
1374    fn test_parse_params_multiple() {
1375        let params = parse_params("grep {{pattern}} {{file}}");
1376        assert_eq!(params.len(), 2);
1377        assert_eq!(params[0].name, "pattern");
1378        assert_eq!(params[1].name, "file");
1379    }
1380
1381    #[test]
1382    fn test_parse_params_deduplicate() {
1383        let params = parse_params("echo {{name}} {{name}}");
1384        assert_eq!(params.len(), 1);
1385    }
1386
1387    #[test]
1388    fn test_parse_params_invalid_name_skipped() {
1389        let params = parse_params("echo {{valid}} {{bad name}} {{ok}}");
1390        assert_eq!(params.len(), 2);
1391        assert_eq!(params[0].name, "valid");
1392        assert_eq!(params[1].name, "ok");
1393    }
1394
1395    #[test]
1396    fn test_parse_params_unclosed_brace() {
1397        let params = parse_params("echo {{unclosed");
1398        assert!(params.is_empty());
1399    }
1400
1401    #[test]
1402    fn test_parse_params_max_20() {
1403        let cmd: String = (0..25)
1404            .map(|i| format!("{{{{p{}}}}}", i))
1405            .collect::<Vec<_>>()
1406            .join(" ");
1407        let params = parse_params(&cmd);
1408        assert_eq!(params.len(), 20);
1409    }
1410
1411    // =========================================================================
1412    // validate_param_name
1413    // =========================================================================
1414
1415    #[test]
1416    fn test_validate_param_name_valid() {
1417        assert!(validate_param_name("path").is_ok());
1418        assert!(validate_param_name("my-param").is_ok());
1419        assert!(validate_param_name("my_param").is_ok());
1420        assert!(validate_param_name("param1").is_ok());
1421    }
1422
1423    #[test]
1424    fn test_validate_param_name_empty() {
1425        assert!(validate_param_name("").is_err());
1426    }
1427
1428    #[test]
1429    fn test_validate_param_name_rejects_braces() {
1430        assert!(validate_param_name("a{b").is_err());
1431        assert!(validate_param_name("a}b").is_err());
1432    }
1433
1434    #[test]
1435    fn test_validate_param_name_rejects_quote() {
1436        assert!(validate_param_name("it's").is_err());
1437    }
1438
1439    #[test]
1440    fn test_validate_param_name_rejects_whitespace() {
1441        assert!(validate_param_name("a b").is_err());
1442    }
1443
1444    // =========================================================================
1445    // substitute_params
1446    // =========================================================================
1447
1448    #[test]
1449    fn test_substitute_simple() {
1450        let mut values = std::collections::HashMap::new();
1451        values.insert("path".to_string(), "/var/log".to_string());
1452        let result = substitute_params("df -h {{path}}", &values);
1453        assert_eq!(result, "df -h '/var/log'");
1454    }
1455
1456    #[test]
1457    fn test_substitute_with_default() {
1458        let values = std::collections::HashMap::new();
1459        let result = substitute_params("df -h {{path:/tmp}}", &values);
1460        assert_eq!(result, "df -h '/tmp'");
1461    }
1462
1463    #[test]
1464    fn test_substitute_overrides_default() {
1465        let mut values = std::collections::HashMap::new();
1466        values.insert("path".to_string(), "/home".to_string());
1467        let result = substitute_params("df -h {{path:/tmp}}", &values);
1468        assert_eq!(result, "df -h '/home'");
1469    }
1470
1471    #[test]
1472    fn test_substitute_escapes_injection() {
1473        let mut values = std::collections::HashMap::new();
1474        values.insert("name".to_string(), "; rm -rf /".to_string());
1475        let result = substitute_params("echo {{name}}", &values);
1476        assert_eq!(result, "echo '; rm -rf /'");
1477    }
1478
1479    #[test]
1480    fn test_substitute_no_recursive_expansion() {
1481        let mut values = std::collections::HashMap::new();
1482        values.insert("a".to_string(), "{{b}}".to_string());
1483        values.insert("b".to_string(), "gotcha".to_string());
1484        let result = substitute_params("echo {{a}}", &values);
1485        assert_eq!(result, "echo '{{b}}'");
1486    }
1487
1488    #[test]
1489    fn test_substitute_default_also_escaped() {
1490        let values = std::collections::HashMap::new();
1491        let result = substitute_params("echo {{x:$(whoami)}}", &values);
1492        assert_eq!(result, "echo '$(whoami)'");
1493    }
1494
1495    // =========================================================================
1496    // sanitize_output
1497    // =========================================================================
1498
1499    #[test]
1500    fn test_sanitize_plain_text() {
1501        assert_eq!(sanitize_output("hello world"), "hello world");
1502    }
1503
1504    #[test]
1505    fn test_sanitize_preserves_newlines_tabs() {
1506        assert_eq!(sanitize_output("line1\nline2\tok"), "line1\nline2\tok");
1507    }
1508
1509    #[test]
1510    fn test_sanitize_strips_csi() {
1511        assert_eq!(sanitize_output("\x1b[31mred\x1b[0m"), "red");
1512    }
1513
1514    #[test]
1515    fn test_sanitize_strips_osc_bel() {
1516        assert_eq!(sanitize_output("\x1b]0;title\x07text"), "text");
1517    }
1518
1519    #[test]
1520    fn test_sanitize_strips_osc_st() {
1521        assert_eq!(sanitize_output("\x1b]52;c;dGVzdA==\x1b\\text"), "text");
1522    }
1523
1524    #[test]
1525    fn test_sanitize_strips_c1_range() {
1526        assert_eq!(sanitize_output("a\u{0090}b\u{009C}c"), "abc");
1527    }
1528
1529    #[test]
1530    fn test_sanitize_strips_control_chars() {
1531        assert_eq!(sanitize_output("a\x01b\x07c"), "abc");
1532    }
1533
1534    #[test]
1535    fn test_sanitize_strips_dcs() {
1536        assert_eq!(sanitize_output("\x1bPdata\x1b\\text"), "text");
1537    }
1538
1539    // =========================================================================
1540    // shell_escape (edge cases)
1541    // =========================================================================
1542
1543    #[test]
1544    fn test_shell_escape_only_single_quotes() {
1545        assert_eq!(shell_escape("'''"), "''\\'''\\'''\\'''");
1546    }
1547
1548    #[test]
1549    fn test_shell_escape_consecutive_single_quotes() {
1550        assert_eq!(shell_escape("a''b"), "'a'\\'''\\''b'");
1551    }
1552
1553    // =========================================================================
1554    // parse_params (edge cases)
1555    // =========================================================================
1556
1557    #[test]
1558    fn test_parse_params_adjacent() {
1559        let params = parse_params("{{a}}{{b}}");
1560        assert_eq!(params.len(), 2);
1561        assert_eq!(params[0].name, "a");
1562        assert_eq!(params[1].name, "b");
1563    }
1564
1565    #[test]
1566    fn test_parse_params_command_is_only_param() {
1567        let params = parse_params("{{cmd}}");
1568        assert_eq!(params.len(), 1);
1569        assert_eq!(params[0].name, "cmd");
1570    }
1571
1572    #[test]
1573    fn test_parse_params_nested_braces_rejected() {
1574        // {{{a}}} -> inner is "{a" which fails validation
1575        let params = parse_params("{{{a}}}");
1576        assert!(params.is_empty());
1577    }
1578
1579    #[test]
1580    fn test_parse_params_colon_empty_default() {
1581        let params = parse_params("echo {{name:}}");
1582        assert_eq!(params.len(), 1);
1583        assert_eq!(params[0].name, "name");
1584        assert_eq!(params[0].default, Some("".to_string()));
1585    }
1586
1587    #[test]
1588    fn test_parse_params_empty_inner() {
1589        let params = parse_params("echo {{}}");
1590        assert!(params.is_empty());
1591    }
1592
1593    #[test]
1594    fn test_parse_params_single_braces_ignored() {
1595        let params = parse_params("echo {notaparam}");
1596        assert!(params.is_empty());
1597    }
1598
1599    #[test]
1600    fn test_parse_params_default_with_colons() {
1601        let params = parse_params("{{url:http://localhost:8080}}");
1602        assert_eq!(params.len(), 1);
1603        assert_eq!(params[0].name, "url");
1604        assert_eq!(params[0].default, Some("http://localhost:8080".to_string()));
1605    }
1606
1607    // =========================================================================
1608    // validate_param_name (edge cases)
1609    // =========================================================================
1610
1611    #[test]
1612    fn test_validate_param_name_unicode() {
1613        assert!(validate_param_name("caf\u{00e9}").is_ok());
1614    }
1615
1616    #[test]
1617    fn test_validate_param_name_hyphen_only() {
1618        assert!(validate_param_name("-").is_ok());
1619    }
1620
1621    #[test]
1622    fn test_validate_param_name_underscore_only() {
1623        assert!(validate_param_name("_").is_ok());
1624    }
1625
1626    #[test]
1627    fn test_validate_param_name_rejects_dot() {
1628        assert!(validate_param_name("a.b").is_err());
1629    }
1630
1631    // =========================================================================
1632    // substitute_params (edge cases)
1633    // =========================================================================
1634
1635    #[test]
1636    fn test_substitute_no_params_passthrough() {
1637        let values = std::collections::HashMap::new();
1638        let result = substitute_params("df -h /tmp", &values);
1639        assert_eq!(result, "df -h /tmp");
1640    }
1641
1642    #[test]
1643    fn test_substitute_missing_param_no_default() {
1644        let values = std::collections::HashMap::new();
1645        let result = substitute_params("echo {{name}}", &values);
1646        assert_eq!(result, "echo ''");
1647    }
1648
1649    #[test]
1650    fn test_substitute_empty_value_falls_to_default() {
1651        let mut values = std::collections::HashMap::new();
1652        values.insert("name".to_string(), "".to_string());
1653        let result = substitute_params("echo {{name:fallback}}", &values);
1654        assert_eq!(result, "echo 'fallback'");
1655    }
1656
1657    #[test]
1658    fn test_substitute_non_ascii_around_params() {
1659        let mut values = std::collections::HashMap::new();
1660        values.insert("x".to_string(), "val".to_string());
1661        let result = substitute_params("\u{00e9}cho {{x}} \u{2603}", &values);
1662        assert_eq!(result, "\u{00e9}cho 'val' \u{2603}");
1663    }
1664
1665    #[test]
1666    fn test_substitute_adjacent_params() {
1667        let mut values = std::collections::HashMap::new();
1668        values.insert("a".to_string(), "x".to_string());
1669        values.insert("b".to_string(), "y".to_string());
1670        let result = substitute_params("{{a}}{{b}}", &values);
1671        assert_eq!(result, "'x''y'");
1672    }
1673
1674    // =========================================================================
1675    // sanitize_output (edge cases)
1676    // =========================================================================
1677
1678    #[test]
1679    fn test_sanitize_empty() {
1680        assert_eq!(sanitize_output(""), "");
1681    }
1682
1683    #[test]
1684    fn test_sanitize_only_escapes() {
1685        assert_eq!(sanitize_output("\x1b[31m\x1b[0m\x1b[1m"), "");
1686    }
1687
1688    #[test]
1689    fn test_sanitize_lone_esc_at_end() {
1690        assert_eq!(sanitize_output("hello\x1b"), "hello");
1691    }
1692
1693    #[test]
1694    fn test_sanitize_truncated_csi_no_terminator() {
1695        assert_eq!(sanitize_output("hello\x1b[123"), "hello");
1696    }
1697
1698    #[test]
1699    fn test_sanitize_apc_sequence() {
1700        assert_eq!(sanitize_output("\x1b_payload\x1b\\visible"), "visible");
1701    }
1702
1703    #[test]
1704    fn test_sanitize_pm_sequence() {
1705        assert_eq!(sanitize_output("\x1b^payload\x1b\\visible"), "visible");
1706    }
1707
1708    #[test]
1709    fn test_sanitize_dcs_terminated_by_bel() {
1710        assert_eq!(sanitize_output("\x1bPdata\x07text"), "text");
1711    }
1712
1713    #[test]
1714    fn test_sanitize_lone_esc_plus_letter() {
1715        assert_eq!(sanitize_output("a\x1bMb"), "ab");
1716    }
1717
1718    #[test]
1719    fn test_sanitize_multiple_mixed_sequences() {
1720        // \x01 (SOH) is stripped but "gone" text after it is preserved
1721        let input = "\x1b[1mbold\x1b[0m \x1b]0;title\x07normal \x01gone";
1722        assert_eq!(sanitize_output(input), "bold normal gone");
1723    }
1724}