1use std::collections::HashMap;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::{Command, ExitStatus, Stdio};
5
6use crate::fs_util;
7
8#[derive(Debug, Clone, PartialEq, Default)]
10pub struct Snippet {
11 pub name: String,
12 pub command: String,
13 pub description: String,
14 pub interactive: bool,
17}
18
19impl Snippet {
20 pub fn remote_command(&self) -> String {
23 if self.interactive {
24 wrap_interactive(&self.command)
25 } else {
26 self.command.clone()
27 }
28 }
29}
30
31const INTERACTIVE_DISPATCH: &str = "case \"${SHELL##*/}\" in bash|zsh) \
35 exec 3>&2 4<&0; exec \"$SHELL\" -ic \"$2\" \"${SHELL##*/}\" \"$1\" 2>/dev/null </dev/null;; \
36 ?*) exec \"$SHELL\" -c \"$1\" \"${SHELL##*/}\";; \
37 *) eval \"$1\";; esac";
38
39const INTERACTIVE_SCRIPT: &str = "( exec 2>&3 3>&- 0<&4 4<&-; eval \"$1\" )";
45
46pub fn wrap_interactive(command: &str) -> String {
50 format!(
51 "exec /bin/sh -c {} sh {} {}",
52 portable_escape(INTERACTIVE_DISPATCH),
53 portable_escape(command),
54 portable_escape(INTERACTIVE_SCRIPT)
55 )
56}
57
58fn portable_escape(s: &str) -> String {
62 let mut out = String::with_capacity(s.len() + 2);
63 out.push('\'');
64 for c in s.chars() {
65 match c {
66 '\'' => out.push_str("'\\''"),
67 '\\' => out.push_str("'\\\\'"),
68 '!' => out.push_str("'\\!'"),
69 _ => out.push(c),
70 }
71 }
72 out.push('\'');
73 out
74}
75
76pub struct SnippetResult {
78 pub status: ExitStatus,
79 pub stdout: String,
80 pub stderr: String,
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct SnippetStore {
86 pub snippets: Vec<Snippet>,
87 pub path_override: Option<PathBuf>,
89 pub targets: HashMap<String, Vec<String>>,
94}
95
96fn config_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
97 paths.map(crate::runtime::env::Paths::snippets_file)
98}
99
100impl SnippetStore {
101 pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
107 let path = match config_path(paths) {
108 Some(p) => p,
109 None => return Self::default(),
110 };
111 let content = match std::fs::read_to_string(&path) {
112 Ok(c) => c,
113 Err(e) if e.kind() == io::ErrorKind::NotFound => {
114 return Self {
115 path_override: Some(path),
116 ..Self::default()
117 };
118 }
119 Err(e) => {
120 log::warn!("[config] Could not read {}: {}", path.display(), e);
121 return Self {
122 path_override: Some(path),
123 ..Self::default()
124 };
125 }
126 };
127 Self {
128 path_override: Some(path),
129 ..Self::parse(&content)
130 }
131 }
132
133 pub fn parse(content: &str) -> Self {
135 let mut snippets = Vec::new();
136 let mut targets: HashMap<String, Vec<String>> = HashMap::new();
137 let mut current: Option<Snippet> = None;
138
139 for line in content.lines() {
140 let trimmed = line.trim();
141 if trimmed.is_empty() || trimmed.starts_with('#') {
142 continue;
143 }
144 if trimmed.starts_with('[') && trimmed.ends_with(']') {
145 if let Some(snippet) = current.take()
146 && !snippet.command.is_empty()
147 && !snippets.iter().any(|s: &Snippet| s.name == snippet.name)
148 {
149 snippets.push(snippet);
150 }
151 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
152 if snippets.iter().any(|s| s.name == name) {
153 current = None;
154 continue;
155 }
156 current = Some(Snippet {
157 name,
158 ..Snippet::default()
159 });
160 } else if let Some(ref mut snippet) = current
161 && let Some((key, value)) = trimmed.split_once('=')
162 {
163 let key = key.trim();
164 let value = value.trim_start().to_string();
167 match key {
168 "command" => snippet.command = value,
169 "description" => snippet.description = value,
170 "interactive" => snippet.interactive = value.trim() == "true",
171 "hosts" => {
172 let aliases: Vec<String> = value
173 .split(',')
174 .map(|a| unescape_alias(a.trim()))
175 .filter(|a| !a.is_empty())
176 .collect();
177 if !aliases.is_empty() {
178 targets.insert(snippet.name.clone(), aliases);
179 }
180 }
181 _ => {}
182 }
183 }
184 }
185 if let Some(snippet) = current
186 && !snippet.command.is_empty()
187 && !snippets.iter().any(|s| s.name == snippet.name)
188 {
189 snippets.push(snippet);
190 }
191 targets.retain(|name, _| snippets.iter().any(|s| &s.name == name));
194 Self {
195 snippets,
196 path_override: None,
197 targets,
198 }
199 }
200
201 pub fn save(&self) -> io::Result<()> {
203 if crate::demo_flag::is_demo() {
204 return Ok(());
205 }
206 let Some(path) = self.path_override.clone() else {
207 return Err(io::Error::new(
208 io::ErrorKind::NotFound,
209 "Could not determine home directory",
210 ));
211 };
212
213 let mut content = String::new();
214 for (i, snippet) in self.snippets.iter().enumerate() {
215 if i > 0 {
216 content.push('\n');
217 }
218 content.push_str(&format!("[{}]\n", snippet.name));
219 content.push_str(&format!("command={}\n", snippet.command));
220 if !snippet.description.is_empty() {
221 content.push_str(&format!("description={}\n", snippet.description));
222 }
223 if snippet.interactive {
224 content.push_str("interactive=true\n");
225 }
226 if let Some(hosts) = self.targets.get(&snippet.name)
227 && !hosts.is_empty()
228 {
229 let joined = hosts
230 .iter()
231 .map(|a| escape_alias(a))
232 .collect::<Vec<_>>()
233 .join(",");
234 content.push_str(&format!("hosts={joined}\n"));
235 }
236 }
237
238 fs_util::atomic_write(&path, content.as_bytes())
239 }
240
241 pub fn targets_for(&self, name: &str) -> &[String] {
243 self.targets.get(name).map(|v| v.as_slice()).unwrap_or(&[])
244 }
245
246 pub fn set_targets(&mut self, name: &str, aliases: Vec<String>) {
248 if aliases.is_empty() {
249 self.targets.remove(name);
250 } else {
251 self.targets.insert(name.to_string(), aliases);
252 }
253 }
254
255 pub fn get(&self, name: &str) -> Option<&Snippet> {
257 self.snippets.iter().find(|s| s.name == name)
258 }
259
260 pub fn set(&mut self, snippet: Snippet) {
262 if let Some(existing) = self.snippets.iter_mut().find(|s| s.name == snippet.name) {
263 *existing = snippet;
264 } else {
265 self.snippets.push(snippet);
266 }
267 }
268
269 pub fn remove(&mut self, name: &str) {
271 self.snippets.retain(|s| s.name != name);
272 self.targets.remove(name);
273 }
274}
275
276fn escape_alias(alias: &str) -> String {
281 alias.replace('%', "%25").replace(',', "%2C")
282}
283
284fn unescape_alias(token: &str) -> String {
287 token.replace("%2C", ",").replace("%25", "%")
288}
289
290pub fn validate_name(name: &str) -> Result<(), String> {
293 if name.trim().is_empty() {
294 return Err(crate::messages::SNIPPET_NAME_EMPTY.to_string());
295 }
296 if name != name.trim() {
297 return Err(crate::messages::SNIPPET_NAME_WHITESPACE.to_string());
298 }
299 if name.contains('#') || name.contains('[') || name.contains(']') {
300 return Err(crate::messages::SNIPPET_NAME_INVALID_CHARS.to_string());
301 }
302 if name.contains(|c: char| c.is_control()) {
303 return Err(crate::messages::SNIPPET_NAME_CONTROL_CHARS.to_string());
304 }
305 Ok(())
306}
307
308pub fn validate_command(command: &str) -> Result<(), String> {
310 if command.trim().is_empty() {
311 return Err(crate::messages::SNIPPET_COMMAND_EMPTY.to_string());
312 }
313 if command.contains(|c: char| c.is_control() && c != '\t') {
314 return Err(crate::messages::SNIPPET_COMMAND_CONTROL_CHARS.to_string());
315 }
316 Ok(())
317}
318
319#[derive(Debug, Clone, PartialEq)]
325pub struct SnippetParam {
326 pub name: String,
327 pub default: Option<String>,
328}
329
330pub fn shell_escape(s: &str) -> String {
333 format!("'{}'", s.replace('\'', "'\\''"))
334}
335
336pub fn parse_params(command: &str) -> Vec<SnippetParam> {
339 let mut params = Vec::new();
340 let mut seen = std::collections::HashSet::new();
341 let bytes = command.as_bytes();
342 let len = bytes.len();
343 let mut i = 0;
344 while i + 3 < len {
345 if bytes[i] == b'{'
346 && bytes.get(i + 1) == Some(&b'{')
347 && let Some(end) = command[i + 2..].find("}}")
348 {
349 let inner = &command[i + 2..i + 2 + end];
350 let (name, default) = if let Some((n, d)) = inner.split_once(':') {
351 (n.to_string(), Some(d.to_string()))
352 } else {
353 (inner.to_string(), None)
354 };
355 if validate_param_name(&name).is_ok() && !seen.contains(&name) && params.len() < 20 {
356 seen.insert(name.clone());
357 params.push(SnippetParam { name, default });
358 }
359 i = i + 2 + end + 2;
360 continue;
361 }
362 i += 1;
363 }
364 params
365}
366
367pub fn count_params(command: &str) -> usize {
371 let mut seen: Vec<&str> = Vec::new();
372 let bytes = command.as_bytes();
373 let len = bytes.len();
374 let mut i = 0;
375 while i + 3 < len {
376 if bytes[i] == b'{'
377 && bytes.get(i + 1) == Some(&b'{')
378 && let Some(end) = command[i + 2..].find("}}")
379 {
380 let inner = &command[i + 2..i + 2 + end];
381 let name = inner.split_once(':').map_or(inner, |(n, _)| n);
382 if validate_param_name(name).is_ok() && !seen.contains(&name) && seen.len() < 20 {
383 seen.push(name);
384 }
385 i = i + 2 + end + 2;
386 continue;
387 }
388 i += 1;
389 }
390 seen.len()
391}
392
393pub fn validate_param_name(name: &str) -> Result<(), String> {
396 if name.is_empty() {
397 return Err(crate::messages::SNIPPET_PARAM_NAME_EMPTY.to_string());
398 }
399 if !name
400 .chars()
401 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
402 {
403 return Err(crate::messages::snippet_param_name_invalid(name));
404 }
405 Ok(())
406}
407
408pub fn substitute_params(
411 command: &str,
412 values: &std::collections::HashMap<String, String>,
413) -> String {
414 let mut result = String::with_capacity(command.len());
415 let bytes = command.as_bytes();
416 let len = bytes.len();
417 let mut i = 0;
418 while i < len {
419 if i + 3 < len
420 && bytes[i] == b'{'
421 && bytes[i + 1] == b'{'
422 && let Some(end) = command[i + 2..].find("}}")
423 {
424 let inner = &command[i + 2..i + 2 + end];
425 let (name, default) = if let Some((n, d)) = inner.split_once(':') {
426 (n, Some(d))
427 } else {
428 (inner, None)
429 };
430 if validate_param_name(name).is_ok() {
434 let value = values
435 .get(name)
436 .filter(|v| !v.is_empty())
437 .map(|v| v.as_str())
438 .or(default)
439 .unwrap_or("");
440 result.push_str(&shell_escape(value));
441 i = i + 2 + end + 2;
442 continue;
443 }
444 }
445 let ch = command[i..].chars().next().unwrap();
447 result.push(ch);
448 i += ch.len_utf8();
449 }
450 result
451}
452
453pub fn sanitize_output(input: &str) -> String {
460 let mut out = String::with_capacity(input.len());
461 let mut chars = input.chars().peekable();
462 while let Some(c) = chars.next() {
463 match c {
464 '\x1b' => {
465 match chars.peek() {
466 Some('[') => {
467 chars.next();
468 while let Some(&ch) = chars.peek() {
470 chars.next();
471 if ('\x40'..='\x7e').contains(&ch) {
472 break;
473 }
474 }
475 }
476 Some(']') | Some('P') | Some('X') | Some('^') | Some('_') => {
477 chars.next();
478 consume_until_st(&mut chars);
480 }
481 _ => {
482 chars.next();
484 }
485 }
486 }
487 '\u{009b}' => {
488 while let Some(&ch) = chars.peek() {
496 chars.next();
497 if ('\x40'..='\x7e').contains(&ch) {
498 break;
499 }
500 }
501 }
502 c if ('\u{0080}'..='\u{009F}').contains(&c) => {
503 }
505 c if c.is_control() && c != '\n' && c != '\t' => {
506 }
508 _ => out.push(c),
509 }
510 }
511 out
512}
513
514fn consume_until_st(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
516 while let Some(&ch) = chars.peek() {
517 if ch == '\x07' {
518 chars.next();
519 break;
520 }
521 if ch == '\x1b' {
522 chars.next();
523 if chars.peek() == Some(&'\\') {
524 chars.next();
525 }
526 break;
527 }
528 chars.next();
529 }
530}
531
532pub const EXIT_COMMAND_NOT_FOUND: i32 = 127;
538
539pub fn not_found_hint_applies(interactive: bool, exit_code: Option<i32>) -> bool {
542 !interactive && exit_code == Some(EXIT_COMMAND_NOT_FOUND)
543}
544
545const MAX_OUTPUT_LINES: usize = 10_000;
548
549const WAIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
553
554const TERM_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
556
557pub struct ChildGuard {
560 inner: std::sync::Mutex<Option<std::process::Child>>,
561 pgid: i32,
562 terminated_at: std::sync::Mutex<Option<std::time::Instant>>,
566}
567
568impl ChildGuard {
569 pub(crate) fn new(child: std::process::Child) -> Self {
570 let pgid = i32::try_from(child.id()).unwrap_or(0);
576 Self {
577 inner: std::sync::Mutex::new(Some(child)),
578 pgid,
579 terminated_at: std::sync::Mutex::new(None),
580 }
581 }
582
583 #[cfg(unix)]
585 fn kill_group(&self) {
586 if self.pgid > 1 {
587 unsafe {
591 libc::kill(-self.pgid, libc::SIGKILL);
592 }
593 }
594 }
595
596 #[cfg(not(unix))]
597 fn kill_group(&self) {}
598
599 pub(crate) fn wait(&self) -> Option<ExitStatus> {
620 let mut escalated = false;
621 loop {
622 let reaped = {
623 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
624 let child = lock.as_mut()?;
625 match child.try_wait() {
626 Ok(Some(status)) => {
627 let _ = lock.take();
628 Some(Some(status))
629 }
630 Ok(None) => None,
631 Err(_) => {
632 let _ = lock.take();
633 Some(None)
634 }
635 }
636 };
637 if let Some(status) = reaped {
638 self.kill_group();
639 return status;
640 }
641 if !escalated && self.grace_expired() {
642 escalated = true;
643 self.kill_group();
644 }
645 std::thread::sleep(WAIT_POLL_INTERVAL);
646 }
647 }
648
649 fn grace_expired(&self) -> bool {
652 self.terminated_at
653 .lock()
654 .unwrap_or_else(|e| e.into_inner())
655 .is_some_and(|t| t.elapsed() >= TERM_GRACE)
656 }
657
658 pub(crate) fn terminate(&self) {
663 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
664 let Some(child) = lock.as_mut() else {
665 return;
666 };
667 if let Ok(Some(_)) = child.try_wait() {
669 return;
670 }
671 *self.terminated_at.lock().unwrap_or_else(|e| e.into_inner()) =
672 Some(std::time::Instant::now());
673 #[cfg(unix)]
677 if self.pgid > 1 {
678 unsafe {
683 libc::kill(-self.pgid, libc::SIGTERM);
684 }
685 return;
686 }
687 let _ = child.kill();
690 }
691
692 fn terminate_and_reap(&self) {
695 self.terminate();
696 let deadline = std::time::Instant::now() + TERM_GRACE;
697 loop {
698 {
699 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
700 let Some(child) = lock.as_mut() else {
701 return;
702 };
703 match child.try_wait() {
704 Ok(Some(_)) | Err(_) => {
705 let _ = lock.take();
706 return;
707 }
708 Ok(None) => {}
709 }
710 }
711 if std::time::Instant::now() >= deadline {
712 break;
713 }
714 std::thread::sleep(WAIT_POLL_INTERVAL);
715 }
716 self.kill_group();
717 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
718 if let Some(child) = lock.as_mut() {
719 let _ = child.kill();
720 let _ = child.wait();
721 }
722 }
723}
724
725impl Drop for ChildGuard {
726 fn drop(&mut self) {
727 self.terminate_and_reap();
729 }
730}
731
732fn read_pipe_capped<R: io::Read>(reader: R, alias: &str, stream: &str) -> String {
739 use io::BufRead;
740 let mut br = io::BufReader::new(reader);
741 let bounded = read_bounded(&mut br, SSH_OUTPUT_MAX_BYTES, alias, stream);
742
743 let mut output = String::new();
744 let mut line_count = 0;
745 let mut rdr = io::BufReader::new(bounded.as_slice());
746 let mut buf = Vec::new();
747 loop {
748 buf.clear();
749 match rdr.read_until(b'\n', &mut buf) {
750 Ok(0) => break, Ok(_) => {
752 if line_count < MAX_OUTPUT_LINES {
753 if line_count > 0 {
754 output.push('\n');
755 }
756 if buf.last() == Some(&b'\n') {
758 buf.pop();
759 if buf.last() == Some(&b'\r') {
760 buf.pop();
761 }
762 }
763 output.push_str(&String::from_utf8_lossy(&buf));
765 line_count += 1;
766 } else {
767 output.push_str("\n[Output truncated at 10,000 lines]");
768 break;
769 }
770 }
771 Err(_) => break,
772 }
773 }
774 output
775}
776
777#[allow(clippy::too_many_arguments)]
790fn base_ssh_command(
791 alias: &str,
792 config_path: &Path,
793 command: &str,
794 askpass: Option<&str>,
795 session_password: Option<&str>,
796 bw_session: Option<&str>,
797 has_active_tunnel: bool,
798 non_interactive: bool,
799 trust_new_host_key: bool,
800) -> Command {
801 let mut cmd = Command::new("ssh");
802 cmd.arg("-F")
803 .arg(config_path)
804 .arg("-o")
805 .arg("ConnectTimeout=10")
806 .arg("-o")
807 .arg("ControlMaster=no")
808 .arg("-o")
809 .arg("ControlPath=none");
810
811 if non_interactive {
812 let strict = if trust_new_host_key {
813 "StrictHostKeyChecking=accept-new"
814 } else {
815 "StrictHostKeyChecking=yes"
816 };
817 cmd.arg("-o")
818 .arg(strict)
819 .arg("-o")
820 .arg(crate::askpass_env::SINGLE_PASSWORD_PROMPT_OPT);
821 if crate::askpass_env::needs_batch_mode(askpass, session_password) {
822 cmd.arg("-o").arg(crate::askpass_env::BATCH_MODE_OPT);
823 }
824 }
825
826 if has_active_tunnel {
827 cmd.arg("-o").arg("ClearAllForwardings=yes");
828 }
829
830 cmd.arg("--").arg(alias).arg(command);
831
832 crate::askpass_env::configure_auth(
833 &mut cmd,
834 alias,
835 config_path,
836 askpass,
837 session_password,
838 bw_session,
839 non_interactive,
840 );
841
842 cmd
843}
844
845fn build_snippet_command(ctx: &crate::ssh_context::SshContext<'_>, command: &str) -> Command {
847 let mut cmd = base_ssh_command(
848 ctx.alias,
849 ctx.config_path,
850 command,
851 ctx.askpass,
852 ctx.session_password,
853 ctx.bw_session,
854 ctx.has_tunnel,
855 true,
856 ctx.trust_new_host_key,
857 );
858 cmd.stdin(Stdio::null())
859 .stdout(Stdio::piped())
860 .stderr(Stdio::piped());
861
862 #[cfg(unix)]
865 unsafe {
869 use std::os::unix::process::CommandExt;
870 cmd.pre_exec(|| {
871 libc::setpgid(0, 0);
872 Ok(())
873 });
874 }
875
876 cmd
877}
878
879fn execute_host(
881 run_id: u64,
882 ctx: &crate::ssh_context::SshContext<'_>,
883 command: &str,
884 tx: &std::sync::mpsc::Sender<crate::event::AppEvent>,
885) -> Option<std::sync::Arc<ChildGuard>> {
886 let alias = ctx.alias;
887 let mut cmd = build_snippet_command(ctx, command);
888
889 match cmd.spawn() {
890 Ok(child) => {
891 let guard = std::sync::Arc::new(ChildGuard::new(child));
892
893 let stdout_pipe = {
895 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
896 lock.as_mut().and_then(|c| c.stdout.take())
897 };
898 let stderr_pipe = {
899 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
900 lock.as_mut().and_then(|c| c.stderr.take())
901 };
902
903 let alias_out = alias.to_string();
905 let stdout_handle = std::thread::spawn(move || match stdout_pipe {
906 Some(pipe) => read_pipe_capped(pipe, &alias_out, "stdout"),
907 None => String::new(),
908 });
909 let alias_err = alias.to_string();
910 let stderr_handle = std::thread::spawn(move || match stderr_pipe {
911 Some(pipe) => read_pipe_capped(pipe, &alias_err, "stderr"),
912 None => String::new(),
913 });
914
915 let stdout_text = stdout_handle.join().unwrap_or_else(|_| {
917 log::warn!("[purple] Snippet stdout reader thread panicked");
918 String::new()
919 });
920 let stderr_text = stderr_handle.join().unwrap_or_else(|_| {
921 log::warn!("[purple] Snippet stderr reader thread panicked");
922 String::new()
923 });
924
925 let exit_code = {
928 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
929 let status = lock.as_mut().and_then(|c| c.wait().ok());
930 let _ = lock.take(); status.and_then(|s| {
932 #[cfg(unix)]
933 {
934 use std::os::unix::process::ExitStatusExt;
935 s.code().or_else(|| s.signal().map(|sig| 128 + sig))
936 }
937 #[cfg(not(unix))]
938 {
939 s.code()
940 }
941 })
942 };
943
944 let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
945 run_id,
946 alias: alias.to_string(),
947 stdout: sanitize_output(&stdout_text),
948 stderr: sanitize_output(&stderr_text),
949 exit_code,
950 });
951
952 Some(guard)
953 }
954 Err(e) => {
955 log::warn!(
956 "[external] snippet ssh spawn failed: run_id={} alias={} err={}",
957 run_id,
958 alias,
959 e
960 );
961 let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
962 run_id,
963 alias: alias.to_string(),
964 stdout: String::new(),
965 stderr: crate::messages::snippet_ssh_launch_failed(&e),
966 exit_code: None,
967 });
968 None
969 }
970 }
971}
972
973#[allow(clippy::too_many_arguments)]
976pub fn spawn_snippet_execution(
977 run_id: u64,
978 askpass_map: Vec<(String, Option<String>)>,
979 config_path: PathBuf,
980 env: std::sync::Arc<crate::runtime::env::Env>,
981 command: String,
982 bw_session: Option<String>,
983 tunnel_aliases: std::collections::HashSet<String>,
984 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
985 tx: std::sync::mpsc::Sender<crate::event::AppEvent>,
986 parallel: bool,
987) {
988 let total = askpass_map.len();
989 let max_concurrent: usize = 20;
990
991 std::thread::Builder::new()
992 .name("snippet-coordinator".into())
993 .spawn(move || {
994 let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
995 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
996
997 if parallel && total > 1 {
998 let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
1000 for _ in 0..max_concurrent.min(total) {
1001 let _ = slot_tx.send(());
1002 }
1003
1004 let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1005 let mut worker_handles = Vec::new();
1006
1007 for (alias, askpass) in askpass_map {
1008 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1009 break;
1010 }
1011
1012 loop {
1014 match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
1015 Ok(()) => break,
1016 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
1017 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1018 break;
1019 }
1020 }
1021 Err(_) => break, }
1023 }
1024
1025 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1026 break;
1027 }
1028
1029 let config_path = config_path.clone();
1030 let env = std::sync::Arc::clone(&env);
1031 let command = command.clone();
1032 let bw_session = bw_session.clone();
1033 let has_tunnel = tunnel_aliases.contains(&alias);
1034 let tx = tx.clone();
1035 let slot_tx = slot_tx.clone();
1036 let guards = guards.clone();
1037 let completed = completed.clone();
1038 let total = total;
1039
1040 let handle = std::thread::spawn(move || {
1041 struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
1043 impl Drop for SlotRelease {
1044 fn drop(&mut self) {
1045 if let Some(tx) = self.0.take() {
1046 let _ = tx.send(());
1047 }
1048 }
1049 }
1050 let _slot = SlotRelease(Some(slot_tx));
1051
1052 let host_ctx = crate::ssh_context::SshContext {
1053 alias: &alias,
1054 config_path: &config_path,
1055 askpass: askpass.as_deref(),
1056 session_password: None,
1057 bw_session: bw_session.as_deref(),
1058 has_tunnel,
1059 trust_new_host_key: false,
1060 env: &env,
1061 };
1062 let guard = execute_host(run_id, &host_ctx, &command, &tx);
1063
1064 if let Some(g) = guard {
1066 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
1067 }
1068
1069 let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1070 let _ = tx.send(crate::event::AppEvent::SnippetProgress {
1071 run_id,
1072 completed: c,
1073 total,
1074 });
1075 });
1077 worker_handles.push(handle);
1078 }
1079
1080 for handle in worker_handles {
1082 let _ = handle.join();
1083 }
1084 } else {
1085 for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
1087 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1088 break;
1089 }
1090
1091 let has_tunnel = tunnel_aliases.contains(&alias);
1092 let host_ctx = crate::ssh_context::SshContext {
1093 alias: &alias,
1094 config_path: &config_path,
1095 askpass: askpass.as_deref(),
1096 session_password: None,
1097 bw_session: bw_session.as_deref(),
1098 has_tunnel,
1099 trust_new_host_key: false,
1100 env: &env,
1101 };
1102 let guard = execute_host(run_id, &host_ctx, &command, &tx);
1103
1104 if let Some(g) = guard {
1105 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
1106 }
1107
1108 let _ = tx.send(crate::event::AppEvent::SnippetProgress {
1109 run_id,
1110 completed: i + 1,
1111 total,
1112 });
1113 }
1114 }
1115
1116 let _ = tx.send(crate::event::AppEvent::SnippetAllDone { run_id });
1117 })
1119 .expect("failed to spawn snippet coordinator");
1120}
1121
1122#[allow(clippy::too_many_arguments)]
1127pub fn run_snippet(
1128 alias: &str,
1129 config_path: &Path,
1130 env: &crate::runtime::env::Env,
1131 command: &str,
1132 askpass: Option<&str>,
1133 bw_session: Option<&str>,
1134 capture: bool,
1135 has_active_tunnel: bool,
1136) -> anyhow::Result<SnippetResult> {
1137 let ctx = crate::ssh_context::SshContext {
1138 alias,
1139 config_path,
1140 askpass,
1141 session_password: None,
1142 bw_session,
1143 has_tunnel: has_active_tunnel,
1144 trust_new_host_key: false,
1145 env,
1146 };
1147 run_snippet_ctx(&ctx, command, capture)
1148}
1149
1150pub fn run_snippet_ctx(
1155 ctx: &crate::ssh_context::SshContext<'_>,
1156 command: &str,
1157 capture: bool,
1158) -> anyhow::Result<SnippetResult> {
1159 let alias = ctx.alias;
1160 crate::runtime::helpers::ensure_vault_cert_for_alias(ctx.env, alias, ctx.config_path);
1164
1165 let mut cmd = base_ssh_command(
1166 alias,
1167 ctx.config_path,
1168 command,
1169 ctx.askpass,
1170 ctx.session_password,
1171 ctx.bw_session,
1172 ctx.has_tunnel,
1173 capture,
1174 ctx.trust_new_host_key,
1175 );
1176
1177 if capture {
1178 cmd.stdin(Stdio::null())
1179 .stdout(Stdio::piped())
1180 .stderr(Stdio::piped());
1181 } else {
1182 cmd.stdin(Stdio::inherit())
1183 .stdout(Stdio::inherit())
1184 .stderr(Stdio::inherit());
1185 }
1186
1187 if capture {
1188 let (status, stdout, stderr) = run_with_bounded_output(&mut cmd, alias)?;
1189 Ok(SnippetResult {
1190 status,
1191 stdout,
1192 stderr,
1193 })
1194 } else {
1195 let status = cmd
1196 .status()
1197 .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
1198
1199 Ok(SnippetResult {
1200 status,
1201 stdout: String::new(),
1202 stderr: String::new(),
1203 })
1204 }
1205}
1206
1207pub const SSH_OUTPUT_MAX_BYTES: usize = 16 * 1024 * 1024;
1214
1215fn run_with_bounded_output(
1221 cmd: &mut Command,
1222 alias: &str,
1223) -> anyhow::Result<(ExitStatus, String, String)> {
1224 let mut child = cmd
1225 .spawn()
1226 .map_err(|e| anyhow::anyhow!("Failed to spawn ssh for '{}': {}", alias, e))?;
1227 let stdout = child.stdout.take();
1228 let stderr = child.stderr.take();
1229
1230 let alias_for_stdout = alias.to_string();
1231 let stdout_handle = std::thread::spawn(move || match stdout {
1232 Some(mut pipe) => {
1233 read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stdout, "stdout")
1234 }
1235 None => Vec::new(),
1236 });
1237 let alias_for_stderr = alias.to_string();
1238 let stderr_handle = std::thread::spawn(move || match stderr {
1239 Some(mut pipe) => {
1240 read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stderr, "stderr")
1241 }
1242 None => Vec::new(),
1243 });
1244
1245 let stdout_bytes = stdout_handle.join().unwrap_or_default();
1253 let stderr_bytes = stderr_handle.join().unwrap_or_default();
1254 let status = child
1255 .wait()
1256 .map_err(|e| anyhow::anyhow!("ssh wait failed for '{}': {}", alias, e))?;
1257
1258 Ok((
1259 status,
1260 String::from_utf8_lossy(&stdout_bytes).to_string(),
1261 String::from_utf8_lossy(&stderr_bytes).to_string(),
1262 ))
1263}
1264
1265fn read_bounded<R: std::io::Read>(
1266 reader: &mut R,
1267 max: usize,
1268 alias: &str,
1269 stream: &str,
1270) -> Vec<u8> {
1271 let mut out = Vec::with_capacity(8 * 1024);
1272 let mut chunk = [0u8; 8 * 1024];
1273 loop {
1274 match reader.read(&mut chunk) {
1275 Ok(0) => break,
1276 Ok(n) => {
1277 if out.len() + n > max {
1278 let allowed = max.saturating_sub(out.len());
1279 out.extend_from_slice(&chunk[..allowed]);
1280 log::warn!(
1281 "[external] ssh {} for '{}' exceeded {} bytes; truncating remainder",
1282 stream,
1283 alias,
1284 max
1285 );
1286 let _ = std::io::copy(reader, &mut std::io::sink());
1289 break;
1290 }
1291 out.extend_from_slice(&chunk[..n]);
1292 }
1293 Err(e) => {
1294 log::debug!("[external] ssh {stream} read error for '{alias}': {e}");
1295 break;
1296 }
1297 }
1298 }
1299 out
1300}
1301
1302pub use crate::snippet_impact::{Category, CommandImpact, Finding, Severity, analyze_command};
1307
1308pub fn filtered_indices(store: &SnippetStore, query: Option<&str>) -> Vec<usize> {
1312 match query {
1313 None | Some("") => (0..store.snippets.len()).collect(),
1314 Some(q) => store
1315 .snippets
1316 .iter()
1317 .enumerate()
1318 .filter(|(_, s)| {
1319 crate::app::contains_ci(&s.name, q)
1320 || crate::app::contains_ci(&s.command, q)
1321 || crate::app::contains_ci(&s.description, q)
1322 })
1323 .map(|(i, _)| i)
1324 .collect(),
1325 }
1326}
1327
1328#[cfg(test)]
1329#[path = "snippet_tests.rs"]
1330mod tests;