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)]
10pub struct Snippet {
11 pub name: String,
12 pub command: String,
13 pub description: String,
14}
15
16pub struct SnippetResult {
18 pub status: ExitStatus,
19 pub stdout: String,
20 pub stderr: String,
21}
22
23#[derive(Debug, Clone, Default)]
25pub struct SnippetStore {
26 pub snippets: Vec<Snippet>,
27 pub path_override: Option<PathBuf>,
29 pub targets: HashMap<String, Vec<String>>,
34}
35
36fn config_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
37 paths.map(crate::runtime::env::Paths::snippets_file)
38}
39
40impl SnippetStore {
41 pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
47 let path = match config_path(paths) {
48 Some(p) => p,
49 None => return Self::default(),
50 };
51 let content = match std::fs::read_to_string(&path) {
52 Ok(c) => c,
53 Err(e) if e.kind() == io::ErrorKind::NotFound => {
54 return Self {
55 path_override: Some(path),
56 ..Self::default()
57 };
58 }
59 Err(e) => {
60 log::warn!("[config] Could not read {}: {}", path.display(), e);
61 return Self {
62 path_override: Some(path),
63 ..Self::default()
64 };
65 }
66 };
67 Self {
68 path_override: Some(path),
69 ..Self::parse(&content)
70 }
71 }
72
73 pub fn parse(content: &str) -> Self {
75 let mut snippets = Vec::new();
76 let mut targets: HashMap<String, Vec<String>> = HashMap::new();
77 let mut current: Option<Snippet> = None;
78
79 for line in content.lines() {
80 let trimmed = line.trim();
81 if trimmed.is_empty() || trimmed.starts_with('#') {
82 continue;
83 }
84 if trimmed.starts_with('[') && trimmed.ends_with(']') {
85 if let Some(snippet) = current.take()
86 && !snippet.command.is_empty()
87 && !snippets.iter().any(|s: &Snippet| s.name == snippet.name)
88 {
89 snippets.push(snippet);
90 }
91 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
92 if snippets.iter().any(|s| s.name == name) {
93 current = None;
94 continue;
95 }
96 current = Some(Snippet {
97 name,
98 command: String::new(),
99 description: String::new(),
100 });
101 } else if let Some(ref mut snippet) = current
102 && let Some((key, value)) = trimmed.split_once('=')
103 {
104 let key = key.trim();
105 let value = value.trim_start().to_string();
108 match key {
109 "command" => snippet.command = value,
110 "description" => snippet.description = value,
111 "hosts" => {
112 let aliases: Vec<String> = value
113 .split(',')
114 .map(|a| unescape_alias(a.trim()))
115 .filter(|a| !a.is_empty())
116 .collect();
117 if !aliases.is_empty() {
118 targets.insert(snippet.name.clone(), aliases);
119 }
120 }
121 _ => {}
122 }
123 }
124 }
125 if let Some(snippet) = current
126 && !snippet.command.is_empty()
127 && !snippets.iter().any(|s| s.name == snippet.name)
128 {
129 snippets.push(snippet);
130 }
131 targets.retain(|name, _| snippets.iter().any(|s| &s.name == name));
134 Self {
135 snippets,
136 path_override: None,
137 targets,
138 }
139 }
140
141 pub fn save(&self) -> io::Result<()> {
143 if crate::demo_flag::is_demo() {
144 return Ok(());
145 }
146 let Some(path) = self.path_override.clone() else {
147 return Err(io::Error::new(
148 io::ErrorKind::NotFound,
149 "Could not determine home directory",
150 ));
151 };
152
153 let mut content = String::new();
154 for (i, snippet) in self.snippets.iter().enumerate() {
155 if i > 0 {
156 content.push('\n');
157 }
158 content.push_str(&format!("[{}]\n", snippet.name));
159 content.push_str(&format!("command={}\n", snippet.command));
160 if !snippet.description.is_empty() {
161 content.push_str(&format!("description={}\n", snippet.description));
162 }
163 if let Some(hosts) = self.targets.get(&snippet.name)
164 && !hosts.is_empty()
165 {
166 let joined = hosts
167 .iter()
168 .map(|a| escape_alias(a))
169 .collect::<Vec<_>>()
170 .join(",");
171 content.push_str(&format!("hosts={joined}\n"));
172 }
173 }
174
175 fs_util::atomic_write(&path, content.as_bytes())
176 }
177
178 pub fn targets_for(&self, name: &str) -> &[String] {
180 self.targets.get(name).map(|v| v.as_slice()).unwrap_or(&[])
181 }
182
183 pub fn set_targets(&mut self, name: &str, aliases: Vec<String>) {
185 if aliases.is_empty() {
186 self.targets.remove(name);
187 } else {
188 self.targets.insert(name.to_string(), aliases);
189 }
190 }
191
192 pub fn get(&self, name: &str) -> Option<&Snippet> {
194 self.snippets.iter().find(|s| s.name == name)
195 }
196
197 pub fn set(&mut self, snippet: Snippet) {
199 if let Some(existing) = self.snippets.iter_mut().find(|s| s.name == snippet.name) {
200 *existing = snippet;
201 } else {
202 self.snippets.push(snippet);
203 }
204 }
205
206 pub fn remove(&mut self, name: &str) {
208 self.snippets.retain(|s| s.name != name);
209 self.targets.remove(name);
210 }
211}
212
213fn escape_alias(alias: &str) -> String {
218 alias.replace('%', "%25").replace(',', "%2C")
219}
220
221fn unescape_alias(token: &str) -> String {
224 token.replace("%2C", ",").replace("%25", "%")
225}
226
227pub fn validate_name(name: &str) -> Result<(), String> {
230 if name.trim().is_empty() {
231 return Err(crate::messages::SNIPPET_NAME_EMPTY.to_string());
232 }
233 if name != name.trim() {
234 return Err(crate::messages::SNIPPET_NAME_WHITESPACE.to_string());
235 }
236 if name.contains('#') || name.contains('[') || name.contains(']') {
237 return Err(crate::messages::SNIPPET_NAME_INVALID_CHARS.to_string());
238 }
239 if name.contains(|c: char| c.is_control()) {
240 return Err(crate::messages::SNIPPET_NAME_CONTROL_CHARS.to_string());
241 }
242 Ok(())
243}
244
245pub fn validate_command(command: &str) -> Result<(), String> {
247 if command.trim().is_empty() {
248 return Err(crate::messages::SNIPPET_COMMAND_EMPTY.to_string());
249 }
250 if command.contains(|c: char| c.is_control() && c != '\t') {
251 return Err(crate::messages::SNIPPET_COMMAND_CONTROL_CHARS.to_string());
252 }
253 Ok(())
254}
255
256#[derive(Debug, Clone, PartialEq)]
262pub struct SnippetParam {
263 pub name: String,
264 pub default: Option<String>,
265}
266
267pub fn shell_escape(s: &str) -> String {
270 format!("'{}'", s.replace('\'', "'\\''"))
271}
272
273pub fn parse_params(command: &str) -> Vec<SnippetParam> {
276 let mut params = Vec::new();
277 let mut seen = std::collections::HashSet::new();
278 let bytes = command.as_bytes();
279 let len = bytes.len();
280 let mut i = 0;
281 while i + 3 < len {
282 if bytes[i] == b'{'
283 && bytes.get(i + 1) == Some(&b'{')
284 && let Some(end) = command[i + 2..].find("}}")
285 {
286 let inner = &command[i + 2..i + 2 + end];
287 let (name, default) = if let Some((n, d)) = inner.split_once(':') {
288 (n.to_string(), Some(d.to_string()))
289 } else {
290 (inner.to_string(), None)
291 };
292 if validate_param_name(&name).is_ok() && !seen.contains(&name) && params.len() < 20 {
293 seen.insert(name.clone());
294 params.push(SnippetParam { name, default });
295 }
296 i = i + 2 + end + 2;
297 continue;
298 }
299 i += 1;
300 }
301 params
302}
303
304pub fn count_params(command: &str) -> usize {
308 let mut seen: Vec<&str> = Vec::new();
309 let bytes = command.as_bytes();
310 let len = bytes.len();
311 let mut i = 0;
312 while i + 3 < len {
313 if bytes[i] == b'{'
314 && bytes.get(i + 1) == Some(&b'{')
315 && let Some(end) = command[i + 2..].find("}}")
316 {
317 let inner = &command[i + 2..i + 2 + end];
318 let name = inner.split_once(':').map_or(inner, |(n, _)| n);
319 if validate_param_name(name).is_ok() && !seen.contains(&name) && seen.len() < 20 {
320 seen.push(name);
321 }
322 i = i + 2 + end + 2;
323 continue;
324 }
325 i += 1;
326 }
327 seen.len()
328}
329
330pub fn validate_param_name(name: &str) -> Result<(), String> {
333 if name.is_empty() {
334 return Err(crate::messages::SNIPPET_PARAM_NAME_EMPTY.to_string());
335 }
336 if !name
337 .chars()
338 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
339 {
340 return Err(crate::messages::snippet_param_name_invalid(name));
341 }
342 Ok(())
343}
344
345pub fn substitute_params(
348 command: &str,
349 values: &std::collections::HashMap<String, String>,
350) -> String {
351 let mut result = String::with_capacity(command.len());
352 let bytes = command.as_bytes();
353 let len = bytes.len();
354 let mut i = 0;
355 while i < len {
356 if i + 3 < len
357 && bytes[i] == b'{'
358 && bytes[i + 1] == b'{'
359 && let Some(end) = command[i + 2..].find("}}")
360 {
361 let inner = &command[i + 2..i + 2 + end];
362 let (name, default) = if let Some((n, d)) = inner.split_once(':') {
363 (n, Some(d))
364 } else {
365 (inner, None)
366 };
367 if validate_param_name(name).is_ok() {
371 let value = values
372 .get(name)
373 .filter(|v| !v.is_empty())
374 .map(|v| v.as_str())
375 .or(default)
376 .unwrap_or("");
377 result.push_str(&shell_escape(value));
378 i = i + 2 + end + 2;
379 continue;
380 }
381 }
382 let ch = command[i..].chars().next().unwrap();
384 result.push(ch);
385 i += ch.len_utf8();
386 }
387 result
388}
389
390pub fn sanitize_output(input: &str) -> String {
397 let mut out = String::with_capacity(input.len());
398 let mut chars = input.chars().peekable();
399 while let Some(c) = chars.next() {
400 match c {
401 '\x1b' => {
402 match chars.peek() {
403 Some('[') => {
404 chars.next();
405 while let Some(&ch) = chars.peek() {
407 chars.next();
408 if ('\x40'..='\x7e').contains(&ch) {
409 break;
410 }
411 }
412 }
413 Some(']') | Some('P') | Some('X') | Some('^') | Some('_') => {
414 chars.next();
415 consume_until_st(&mut chars);
417 }
418 _ => {
419 chars.next();
421 }
422 }
423 }
424 '\u{009b}' => {
425 while let Some(&ch) = chars.peek() {
433 chars.next();
434 if ('\x40'..='\x7e').contains(&ch) {
435 break;
436 }
437 }
438 }
439 c if ('\u{0080}'..='\u{009F}').contains(&c) => {
440 }
442 c if c.is_control() && c != '\n' && c != '\t' => {
443 }
445 _ => out.push(c),
446 }
447 }
448 out
449}
450
451fn consume_until_st(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
453 while let Some(&ch) = chars.peek() {
454 if ch == '\x07' {
455 chars.next();
456 break;
457 }
458 if ch == '\x1b' {
459 chars.next();
460 if chars.peek() == Some(&'\\') {
461 chars.next();
462 }
463 break;
464 }
465 chars.next();
466 }
467}
468
469const MAX_OUTPUT_LINES: usize = 10_000;
476
477const WAIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
481
482const TERM_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
484
485pub struct ChildGuard {
488 inner: std::sync::Mutex<Option<std::process::Child>>,
489 pgid: i32,
490 terminated_at: std::sync::Mutex<Option<std::time::Instant>>,
494}
495
496impl ChildGuard {
497 pub(crate) fn new(child: std::process::Child) -> Self {
498 let pgid = i32::try_from(child.id()).unwrap_or(0);
504 Self {
505 inner: std::sync::Mutex::new(Some(child)),
506 pgid,
507 terminated_at: std::sync::Mutex::new(None),
508 }
509 }
510
511 #[cfg(unix)]
513 fn kill_group(&self) {
514 if self.pgid > 1 {
515 unsafe {
519 libc::kill(-self.pgid, libc::SIGKILL);
520 }
521 }
522 }
523
524 #[cfg(not(unix))]
525 fn kill_group(&self) {}
526
527 pub(crate) fn wait(&self) -> Option<ExitStatus> {
548 let mut escalated = false;
549 loop {
550 let reaped = {
551 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
552 let child = lock.as_mut()?;
553 match child.try_wait() {
554 Ok(Some(status)) => {
555 let _ = lock.take();
556 Some(Some(status))
557 }
558 Ok(None) => None,
559 Err(_) => {
560 let _ = lock.take();
561 Some(None)
562 }
563 }
564 };
565 if let Some(status) = reaped {
566 self.kill_group();
567 return status;
568 }
569 if !escalated && self.grace_expired() {
570 escalated = true;
571 self.kill_group();
572 }
573 std::thread::sleep(WAIT_POLL_INTERVAL);
574 }
575 }
576
577 fn grace_expired(&self) -> bool {
580 self.terminated_at
581 .lock()
582 .unwrap_or_else(|e| e.into_inner())
583 .is_some_and(|t| t.elapsed() >= TERM_GRACE)
584 }
585
586 pub(crate) fn terminate(&self) {
591 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
592 let Some(child) = lock.as_mut() else {
593 return;
594 };
595 if let Ok(Some(_)) = child.try_wait() {
597 return;
598 }
599 *self.terminated_at.lock().unwrap_or_else(|e| e.into_inner()) =
600 Some(std::time::Instant::now());
601 #[cfg(unix)]
605 if self.pgid > 1 {
606 unsafe {
611 libc::kill(-self.pgid, libc::SIGTERM);
612 }
613 return;
614 }
615 let _ = child.kill();
618 }
619
620 fn terminate_and_reap(&self) {
623 self.terminate();
624 let deadline = std::time::Instant::now() + TERM_GRACE;
625 loop {
626 {
627 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
628 let Some(child) = lock.as_mut() else {
629 return;
630 };
631 match child.try_wait() {
632 Ok(Some(_)) | Err(_) => {
633 let _ = lock.take();
634 return;
635 }
636 Ok(None) => {}
637 }
638 }
639 if std::time::Instant::now() >= deadline {
640 break;
641 }
642 std::thread::sleep(WAIT_POLL_INTERVAL);
643 }
644 self.kill_group();
645 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
646 if let Some(child) = lock.as_mut() {
647 let _ = child.kill();
648 let _ = child.wait();
649 }
650 }
651}
652
653impl Drop for ChildGuard {
654 fn drop(&mut self) {
655 self.terminate_and_reap();
657 }
658}
659
660fn read_pipe_capped<R: io::Read>(reader: R, alias: &str, stream: &str) -> String {
667 use io::BufRead;
668 let mut br = io::BufReader::new(reader);
669 let bounded = read_bounded(&mut br, SSH_OUTPUT_MAX_BYTES, alias, stream);
670
671 let mut output = String::new();
672 let mut line_count = 0;
673 let mut rdr = io::BufReader::new(bounded.as_slice());
674 let mut buf = Vec::new();
675 loop {
676 buf.clear();
677 match rdr.read_until(b'\n', &mut buf) {
678 Ok(0) => break, Ok(_) => {
680 if line_count < MAX_OUTPUT_LINES {
681 if line_count > 0 {
682 output.push('\n');
683 }
684 if buf.last() == Some(&b'\n') {
686 buf.pop();
687 if buf.last() == Some(&b'\r') {
688 buf.pop();
689 }
690 }
691 output.push_str(&String::from_utf8_lossy(&buf));
693 line_count += 1;
694 } else {
695 output.push_str("\n[Output truncated at 10,000 lines]");
696 break;
697 }
698 }
699 Err(_) => break,
700 }
701 }
702 output
703}
704
705#[allow(clippy::too_many_arguments)]
718fn base_ssh_command(
719 alias: &str,
720 config_path: &Path,
721 command: &str,
722 askpass: Option<&str>,
723 session_password: Option<&str>,
724 bw_session: Option<&str>,
725 has_active_tunnel: bool,
726 non_interactive: bool,
727 trust_new_host_key: bool,
728) -> Command {
729 let mut cmd = Command::new("ssh");
730 cmd.arg("-F")
731 .arg(config_path)
732 .arg("-o")
733 .arg("ConnectTimeout=10")
734 .arg("-o")
735 .arg("ControlMaster=no")
736 .arg("-o")
737 .arg("ControlPath=none");
738
739 if non_interactive {
740 let strict = if trust_new_host_key {
741 "StrictHostKeyChecking=accept-new"
742 } else {
743 "StrictHostKeyChecking=yes"
744 };
745 cmd.arg("-o")
746 .arg(strict)
747 .arg("-o")
748 .arg(crate::askpass_env::SINGLE_PASSWORD_PROMPT_OPT);
749 if crate::askpass_env::needs_batch_mode(askpass, session_password) {
750 cmd.arg("-o").arg(crate::askpass_env::BATCH_MODE_OPT);
751 }
752 }
753
754 if has_active_tunnel {
755 cmd.arg("-o").arg("ClearAllForwardings=yes");
756 }
757
758 cmd.arg("--").arg(alias).arg(command);
759
760 crate::askpass_env::configure_auth(
761 &mut cmd,
762 alias,
763 config_path,
764 askpass,
765 session_password,
766 bw_session,
767 non_interactive,
768 );
769
770 cmd
771}
772
773fn build_snippet_command(ctx: &crate::ssh_context::SshContext<'_>, command: &str) -> Command {
775 let mut cmd = base_ssh_command(
776 ctx.alias,
777 ctx.config_path,
778 command,
779 ctx.askpass,
780 ctx.session_password,
781 ctx.bw_session,
782 ctx.has_tunnel,
783 true,
784 ctx.trust_new_host_key,
785 );
786 cmd.stdin(Stdio::null())
787 .stdout(Stdio::piped())
788 .stderr(Stdio::piped());
789
790 #[cfg(unix)]
793 unsafe {
797 use std::os::unix::process::CommandExt;
798 cmd.pre_exec(|| {
799 libc::setpgid(0, 0);
800 Ok(())
801 });
802 }
803
804 cmd
805}
806
807fn execute_host(
809 run_id: u64,
810 ctx: &crate::ssh_context::SshContext<'_>,
811 command: &str,
812 tx: &std::sync::mpsc::Sender<crate::event::AppEvent>,
813) -> Option<std::sync::Arc<ChildGuard>> {
814 let alias = ctx.alias;
815 let mut cmd = build_snippet_command(ctx, command);
816
817 match cmd.spawn() {
818 Ok(child) => {
819 let guard = std::sync::Arc::new(ChildGuard::new(child));
820
821 let stdout_pipe = {
823 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
824 lock.as_mut().and_then(|c| c.stdout.take())
825 };
826 let stderr_pipe = {
827 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
828 lock.as_mut().and_then(|c| c.stderr.take())
829 };
830
831 let alias_out = alias.to_string();
833 let stdout_handle = std::thread::spawn(move || match stdout_pipe {
834 Some(pipe) => read_pipe_capped(pipe, &alias_out, "stdout"),
835 None => String::new(),
836 });
837 let alias_err = alias.to_string();
838 let stderr_handle = std::thread::spawn(move || match stderr_pipe {
839 Some(pipe) => read_pipe_capped(pipe, &alias_err, "stderr"),
840 None => String::new(),
841 });
842
843 let stdout_text = stdout_handle.join().unwrap_or_else(|_| {
845 log::warn!("[purple] Snippet stdout reader thread panicked");
846 String::new()
847 });
848 let stderr_text = stderr_handle.join().unwrap_or_else(|_| {
849 log::warn!("[purple] Snippet stderr reader thread panicked");
850 String::new()
851 });
852
853 let exit_code = {
856 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
857 let status = lock.as_mut().and_then(|c| c.wait().ok());
858 let _ = lock.take(); status.and_then(|s| {
860 #[cfg(unix)]
861 {
862 use std::os::unix::process::ExitStatusExt;
863 s.code().or_else(|| s.signal().map(|sig| 128 + sig))
864 }
865 #[cfg(not(unix))]
866 {
867 s.code()
868 }
869 })
870 };
871
872 let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
873 run_id,
874 alias: alias.to_string(),
875 stdout: sanitize_output(&stdout_text),
876 stderr: sanitize_output(&stderr_text),
877 exit_code,
878 });
879
880 Some(guard)
881 }
882 Err(e) => {
883 log::warn!(
884 "[external] snippet ssh spawn failed: run_id={} alias={} err={}",
885 run_id,
886 alias,
887 e
888 );
889 let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
890 run_id,
891 alias: alias.to_string(),
892 stdout: String::new(),
893 stderr: crate::messages::snippet_ssh_launch_failed(&e),
894 exit_code: None,
895 });
896 None
897 }
898 }
899}
900
901#[allow(clippy::too_many_arguments)]
904pub fn spawn_snippet_execution(
905 run_id: u64,
906 askpass_map: Vec<(String, Option<String>)>,
907 config_path: PathBuf,
908 env: std::sync::Arc<crate::runtime::env::Env>,
909 command: String,
910 bw_session: Option<String>,
911 tunnel_aliases: std::collections::HashSet<String>,
912 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
913 tx: std::sync::mpsc::Sender<crate::event::AppEvent>,
914 parallel: bool,
915) {
916 let total = askpass_map.len();
917 let max_concurrent: usize = 20;
918
919 std::thread::Builder::new()
920 .name("snippet-coordinator".into())
921 .spawn(move || {
922 let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
923 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
924
925 if parallel && total > 1 {
926 let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
928 for _ in 0..max_concurrent.min(total) {
929 let _ = slot_tx.send(());
930 }
931
932 let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
933 let mut worker_handles = Vec::new();
934
935 for (alias, askpass) in askpass_map {
936 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
937 break;
938 }
939
940 loop {
942 match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
943 Ok(()) => break,
944 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
945 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
946 break;
947 }
948 }
949 Err(_) => break, }
951 }
952
953 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
954 break;
955 }
956
957 let config_path = config_path.clone();
958 let env = std::sync::Arc::clone(&env);
959 let command = command.clone();
960 let bw_session = bw_session.clone();
961 let has_tunnel = tunnel_aliases.contains(&alias);
962 let tx = tx.clone();
963 let slot_tx = slot_tx.clone();
964 let guards = guards.clone();
965 let completed = completed.clone();
966 let total = total;
967
968 let handle = std::thread::spawn(move || {
969 struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
971 impl Drop for SlotRelease {
972 fn drop(&mut self) {
973 if let Some(tx) = self.0.take() {
974 let _ = tx.send(());
975 }
976 }
977 }
978 let _slot = SlotRelease(Some(slot_tx));
979
980 let host_ctx = crate::ssh_context::SshContext {
981 alias: &alias,
982 config_path: &config_path,
983 askpass: askpass.as_deref(),
984 session_password: None,
985 bw_session: bw_session.as_deref(),
986 has_tunnel,
987 trust_new_host_key: false,
988 env: &env,
989 };
990 let guard = execute_host(run_id, &host_ctx, &command, &tx);
991
992 if let Some(g) = guard {
994 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
995 }
996
997 let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
998 let _ = tx.send(crate::event::AppEvent::SnippetProgress {
999 run_id,
1000 completed: c,
1001 total,
1002 });
1003 });
1005 worker_handles.push(handle);
1006 }
1007
1008 for handle in worker_handles {
1010 let _ = handle.join();
1011 }
1012 } else {
1013 for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
1015 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1016 break;
1017 }
1018
1019 let has_tunnel = tunnel_aliases.contains(&alias);
1020 let host_ctx = crate::ssh_context::SshContext {
1021 alias: &alias,
1022 config_path: &config_path,
1023 askpass: askpass.as_deref(),
1024 session_password: None,
1025 bw_session: bw_session.as_deref(),
1026 has_tunnel,
1027 trust_new_host_key: false,
1028 env: &env,
1029 };
1030 let guard = execute_host(run_id, &host_ctx, &command, &tx);
1031
1032 if let Some(g) = guard {
1033 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
1034 }
1035
1036 let _ = tx.send(crate::event::AppEvent::SnippetProgress {
1037 run_id,
1038 completed: i + 1,
1039 total,
1040 });
1041 }
1042 }
1043
1044 let _ = tx.send(crate::event::AppEvent::SnippetAllDone { run_id });
1045 })
1047 .expect("failed to spawn snippet coordinator");
1048}
1049
1050#[allow(clippy::too_many_arguments)]
1055pub fn run_snippet(
1056 alias: &str,
1057 config_path: &Path,
1058 env: &crate::runtime::env::Env,
1059 command: &str,
1060 askpass: Option<&str>,
1061 bw_session: Option<&str>,
1062 capture: bool,
1063 has_active_tunnel: bool,
1064) -> anyhow::Result<SnippetResult> {
1065 let ctx = crate::ssh_context::SshContext {
1066 alias,
1067 config_path,
1068 askpass,
1069 session_password: None,
1070 bw_session,
1071 has_tunnel: has_active_tunnel,
1072 trust_new_host_key: false,
1073 env,
1074 };
1075 run_snippet_ctx(&ctx, command, capture)
1076}
1077
1078pub fn run_snippet_ctx(
1083 ctx: &crate::ssh_context::SshContext<'_>,
1084 command: &str,
1085 capture: bool,
1086) -> anyhow::Result<SnippetResult> {
1087 let alias = ctx.alias;
1088 crate::runtime::helpers::ensure_vault_cert_for_alias(ctx.env, alias, ctx.config_path);
1092
1093 let mut cmd = base_ssh_command(
1094 alias,
1095 ctx.config_path,
1096 command,
1097 ctx.askpass,
1098 ctx.session_password,
1099 ctx.bw_session,
1100 ctx.has_tunnel,
1101 capture,
1102 ctx.trust_new_host_key,
1103 );
1104
1105 if capture {
1106 cmd.stdin(Stdio::null())
1107 .stdout(Stdio::piped())
1108 .stderr(Stdio::piped());
1109 } else {
1110 cmd.stdin(Stdio::inherit())
1111 .stdout(Stdio::inherit())
1112 .stderr(Stdio::inherit());
1113 }
1114
1115 if capture {
1116 let (status, stdout, stderr) = run_with_bounded_output(&mut cmd, alias)?;
1117 Ok(SnippetResult {
1118 status,
1119 stdout,
1120 stderr,
1121 })
1122 } else {
1123 let status = cmd
1124 .status()
1125 .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
1126
1127 Ok(SnippetResult {
1128 status,
1129 stdout: String::new(),
1130 stderr: String::new(),
1131 })
1132 }
1133}
1134
1135pub const SSH_OUTPUT_MAX_BYTES: usize = 16 * 1024 * 1024;
1142
1143fn run_with_bounded_output(
1149 cmd: &mut Command,
1150 alias: &str,
1151) -> anyhow::Result<(ExitStatus, String, String)> {
1152 let mut child = cmd
1153 .spawn()
1154 .map_err(|e| anyhow::anyhow!("Failed to spawn ssh for '{}': {}", alias, e))?;
1155 let stdout = child.stdout.take();
1156 let stderr = child.stderr.take();
1157
1158 let alias_for_stdout = alias.to_string();
1159 let stdout_handle = std::thread::spawn(move || match stdout {
1160 Some(mut pipe) => {
1161 read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stdout, "stdout")
1162 }
1163 None => Vec::new(),
1164 });
1165 let alias_for_stderr = alias.to_string();
1166 let stderr_handle = std::thread::spawn(move || match stderr {
1167 Some(mut pipe) => {
1168 read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stderr, "stderr")
1169 }
1170 None => Vec::new(),
1171 });
1172
1173 let stdout_bytes = stdout_handle.join().unwrap_or_default();
1181 let stderr_bytes = stderr_handle.join().unwrap_or_default();
1182 let status = child
1183 .wait()
1184 .map_err(|e| anyhow::anyhow!("ssh wait failed for '{}': {}", alias, e))?;
1185
1186 Ok((
1187 status,
1188 String::from_utf8_lossy(&stdout_bytes).to_string(),
1189 String::from_utf8_lossy(&stderr_bytes).to_string(),
1190 ))
1191}
1192
1193fn read_bounded<R: std::io::Read>(
1194 reader: &mut R,
1195 max: usize,
1196 alias: &str,
1197 stream: &str,
1198) -> Vec<u8> {
1199 let mut out = Vec::with_capacity(8 * 1024);
1200 let mut chunk = [0u8; 8 * 1024];
1201 loop {
1202 match reader.read(&mut chunk) {
1203 Ok(0) => break,
1204 Ok(n) => {
1205 if out.len() + n > max {
1206 let allowed = max.saturating_sub(out.len());
1207 out.extend_from_slice(&chunk[..allowed]);
1208 log::warn!(
1209 "[external] ssh {} for '{}' exceeded {} bytes; truncating remainder",
1210 stream,
1211 alias,
1212 max
1213 );
1214 let _ = std::io::copy(reader, &mut std::io::sink());
1217 break;
1218 }
1219 out.extend_from_slice(&chunk[..n]);
1220 }
1221 Err(e) => {
1222 log::debug!("[external] ssh {stream} read error for '{alias}': {e}");
1223 break;
1224 }
1225 }
1226 }
1227 out
1228}
1229
1230pub use crate::snippet_impact::{Category, CommandImpact, Finding, Severity, analyze_command};
1235
1236pub fn filtered_indices(store: &SnippetStore, query: Option<&str>) -> Vec<usize> {
1240 match query {
1241 None | Some("") => (0..store.snippets.len()).collect(),
1242 Some(q) => store
1243 .snippets
1244 .iter()
1245 .enumerate()
1246 .filter(|(_, s)| {
1247 crate::app::contains_ci(&s.name, q)
1248 || crate::app::contains_ci(&s.command, q)
1249 || crate::app::contains_ci(&s.description, q)
1250 })
1251 .map(|(i, _)| i)
1252 .collect(),
1253 }
1254}
1255
1256#[cfg(test)]
1257#[path = "snippet_tests.rs"]
1258mod tests;