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