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