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
477pub struct ChildGuard {
480 inner: std::sync::Mutex<Option<std::process::Child>>,
481 pgid: i32,
482}
483
484impl ChildGuard {
485 fn new(child: std::process::Child) -> Self {
486 let pgid = i32::try_from(child.id()).unwrap_or(0);
492 Self {
493 inner: std::sync::Mutex::new(Some(child)),
494 pgid,
495 }
496 }
497}
498
499impl Drop for ChildGuard {
500 fn drop(&mut self) {
501 let mut lock = self.inner.lock().unwrap_or_else(|e| e.into_inner());
502 if let Some(ref mut child) = *lock {
503 if let Ok(Some(_)) = child.try_wait() {
505 return;
506 }
507 #[cfg(unix)]
511 if self.pgid > 1 {
512 unsafe {
518 libc::kill(-self.pgid, libc::SIGTERM);
519 }
520 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
522 loop {
523 if let Ok(Some(_)) = child.try_wait() {
524 return;
525 }
526 if std::time::Instant::now() >= deadline {
527 break;
528 }
529 std::thread::sleep(std::time::Duration::from_millis(50));
530 }
531 unsafe {
533 libc::kill(-self.pgid, libc::SIGKILL);
534 }
535 }
536 let _ = child.kill();
539 let _ = child.wait();
540 }
541 }
542}
543
544fn read_pipe_capped<R: io::Read>(reader: R, alias: &str, stream: &str) -> String {
551 use io::BufRead;
552 let mut br = io::BufReader::new(reader);
553 let bounded = read_bounded(&mut br, SSH_OUTPUT_MAX_BYTES, alias, stream);
554
555 let mut output = String::new();
556 let mut line_count = 0;
557 let mut rdr = io::BufReader::new(bounded.as_slice());
558 let mut buf = Vec::new();
559 loop {
560 buf.clear();
561 match rdr.read_until(b'\n', &mut buf) {
562 Ok(0) => break, Ok(_) => {
564 if line_count < MAX_OUTPUT_LINES {
565 if line_count > 0 {
566 output.push('\n');
567 }
568 if buf.last() == Some(&b'\n') {
570 buf.pop();
571 if buf.last() == Some(&b'\r') {
572 buf.pop();
573 }
574 }
575 output.push_str(&String::from_utf8_lossy(&buf));
577 line_count += 1;
578 } else {
579 output.push_str("\n[Output truncated at 10,000 lines]");
580 break;
581 }
582 }
583 Err(_) => break,
584 }
585 }
586 output
587}
588
589fn base_ssh_command(
599 alias: &str,
600 config_path: &Path,
601 command: &str,
602 askpass: Option<&str>,
603 bw_session: Option<&str>,
604 has_active_tunnel: bool,
605 non_interactive: bool,
606) -> Command {
607 let mut cmd = Command::new("ssh");
608 cmd.arg("-F")
609 .arg(config_path)
610 .arg("-o")
611 .arg("ConnectTimeout=10")
612 .arg("-o")
613 .arg("ControlMaster=no")
614 .arg("-o")
615 .arg("ControlPath=none");
616
617 if non_interactive {
618 cmd.arg("-o").arg("StrictHostKeyChecking=yes");
619 }
620
621 if has_active_tunnel {
622 cmd.arg("-o").arg("ClearAllForwardings=yes");
623 }
624
625 cmd.arg("--").arg(alias).arg(command);
626
627 if askpass.is_some() {
628 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
629 }
630
631 if let Some(token) = bw_session {
632 cmd.env("BW_SESSION", token);
633 }
634
635 cmd
636}
637
638fn build_snippet_command(
640 alias: &str,
641 config_path: &Path,
642 command: &str,
643 askpass: Option<&str>,
644 bw_session: Option<&str>,
645 has_active_tunnel: bool,
646) -> Command {
647 let mut cmd = base_ssh_command(
648 alias,
649 config_path,
650 command,
651 askpass,
652 bw_session,
653 has_active_tunnel,
654 true,
655 );
656 cmd.stdin(Stdio::null())
657 .stdout(Stdio::piped())
658 .stderr(Stdio::piped());
659
660 #[cfg(unix)]
663 unsafe {
667 use std::os::unix::process::CommandExt;
668 cmd.pre_exec(|| {
669 libc::setpgid(0, 0);
670 Ok(())
671 });
672 }
673
674 cmd
675}
676
677fn execute_host(
679 run_id: u64,
680 ctx: &crate::ssh_context::SshContext<'_>,
681 command: &str,
682 tx: &std::sync::mpsc::Sender<crate::event::AppEvent>,
683) -> Option<std::sync::Arc<ChildGuard>> {
684 let alias = ctx.alias;
685 let mut cmd = build_snippet_command(
686 alias,
687 ctx.config_path,
688 command,
689 ctx.askpass,
690 ctx.bw_session,
691 ctx.has_tunnel,
692 );
693
694 match cmd.spawn() {
695 Ok(child) => {
696 let guard = std::sync::Arc::new(ChildGuard::new(child));
697
698 let stdout_pipe = {
700 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
701 lock.as_mut().and_then(|c| c.stdout.take())
702 };
703 let stderr_pipe = {
704 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
705 lock.as_mut().and_then(|c| c.stderr.take())
706 };
707
708 let alias_out = alias.to_string();
710 let stdout_handle = std::thread::spawn(move || match stdout_pipe {
711 Some(pipe) => read_pipe_capped(pipe, &alias_out, "stdout"),
712 None => String::new(),
713 });
714 let alias_err = alias.to_string();
715 let stderr_handle = std::thread::spawn(move || match stderr_pipe {
716 Some(pipe) => read_pipe_capped(pipe, &alias_err, "stderr"),
717 None => String::new(),
718 });
719
720 let stdout_text = stdout_handle.join().unwrap_or_else(|_| {
722 log::warn!("[purple] Snippet stdout reader thread panicked");
723 String::new()
724 });
725 let stderr_text = stderr_handle.join().unwrap_or_else(|_| {
726 log::warn!("[purple] Snippet stderr reader thread panicked");
727 String::new()
728 });
729
730 let exit_code = {
733 let mut lock = guard.inner.lock().unwrap_or_else(|e| e.into_inner());
734 let status = lock.as_mut().and_then(|c| c.wait().ok());
735 let _ = lock.take(); status.and_then(|s| {
737 #[cfg(unix)]
738 {
739 use std::os::unix::process::ExitStatusExt;
740 s.code().or_else(|| s.signal().map(|sig| 128 + sig))
741 }
742 #[cfg(not(unix))]
743 {
744 s.code()
745 }
746 })
747 };
748
749 let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
750 run_id,
751 alias: alias.to_string(),
752 stdout: sanitize_output(&stdout_text),
753 stderr: sanitize_output(&stderr_text),
754 exit_code,
755 });
756
757 Some(guard)
758 }
759 Err(e) => {
760 log::warn!(
761 "[external] snippet ssh spawn failed: run_id={} alias={} err={}",
762 run_id,
763 alias,
764 e
765 );
766 let _ = tx.send(crate::event::AppEvent::SnippetHostDone {
767 run_id,
768 alias: alias.to_string(),
769 stdout: String::new(),
770 stderr: crate::messages::snippet_ssh_launch_failed(&e),
771 exit_code: None,
772 });
773 None
774 }
775 }
776}
777
778#[allow(clippy::too_many_arguments)]
781pub fn spawn_snippet_execution(
782 run_id: u64,
783 askpass_map: Vec<(String, Option<String>)>,
784 config_path: PathBuf,
785 env: std::sync::Arc<crate::runtime::env::Env>,
786 command: String,
787 bw_session: Option<String>,
788 tunnel_aliases: std::collections::HashSet<String>,
789 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
790 tx: std::sync::mpsc::Sender<crate::event::AppEvent>,
791 parallel: bool,
792) {
793 let total = askpass_map.len();
794 let max_concurrent: usize = 20;
795
796 std::thread::Builder::new()
797 .name("snippet-coordinator".into())
798 .spawn(move || {
799 let guards: std::sync::Arc<std::sync::Mutex<Vec<std::sync::Arc<ChildGuard>>>> =
800 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
801
802 if parallel && total > 1 {
803 let (slot_tx, slot_rx) = std::sync::mpsc::channel::<()>();
805 for _ in 0..max_concurrent.min(total) {
806 let _ = slot_tx.send(());
807 }
808
809 let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
810 let mut worker_handles = Vec::new();
811
812 for (alias, askpass) in askpass_map {
813 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
814 break;
815 }
816
817 loop {
819 match slot_rx.recv_timeout(std::time::Duration::from_millis(100)) {
820 Ok(()) => break,
821 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
822 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
823 break;
824 }
825 }
826 Err(_) => break, }
828 }
829
830 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
831 break;
832 }
833
834 let config_path = config_path.clone();
835 let env = std::sync::Arc::clone(&env);
836 let command = command.clone();
837 let bw_session = bw_session.clone();
838 let has_tunnel = tunnel_aliases.contains(&alias);
839 let tx = tx.clone();
840 let slot_tx = slot_tx.clone();
841 let guards = guards.clone();
842 let completed = completed.clone();
843 let total = total;
844
845 let handle = std::thread::spawn(move || {
846 struct SlotRelease(Option<std::sync::mpsc::Sender<()>>);
848 impl Drop for SlotRelease {
849 fn drop(&mut self) {
850 if let Some(tx) = self.0.take() {
851 let _ = tx.send(());
852 }
853 }
854 }
855 let _slot = SlotRelease(Some(slot_tx));
856
857 let host_ctx = crate::ssh_context::SshContext {
858 alias: &alias,
859 config_path: &config_path,
860 askpass: askpass.as_deref(),
861 bw_session: bw_session.as_deref(),
862 has_tunnel,
863 env: &env,
864 };
865 let guard = execute_host(run_id, &host_ctx, &command, &tx);
866
867 if let Some(g) = guard {
869 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
870 }
871
872 let c = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
873 let _ = tx.send(crate::event::AppEvent::SnippetProgress {
874 run_id,
875 completed: c,
876 total,
877 });
878 });
880 worker_handles.push(handle);
881 }
882
883 for handle in worker_handles {
885 let _ = handle.join();
886 }
887 } else {
888 for (i, (alias, askpass)) in askpass_map.into_iter().enumerate() {
890 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
891 break;
892 }
893
894 let has_tunnel = tunnel_aliases.contains(&alias);
895 let host_ctx = crate::ssh_context::SshContext {
896 alias: &alias,
897 config_path: &config_path,
898 askpass: askpass.as_deref(),
899 bw_session: bw_session.as_deref(),
900 has_tunnel,
901 env: &env,
902 };
903 let guard = execute_host(run_id, &host_ctx, &command, &tx);
904
905 if let Some(g) = guard {
906 guards.lock().unwrap_or_else(|e| e.into_inner()).push(g);
907 }
908
909 let _ = tx.send(crate::event::AppEvent::SnippetProgress {
910 run_id,
911 completed: i + 1,
912 total,
913 });
914 }
915 }
916
917 let _ = tx.send(crate::event::AppEvent::SnippetAllDone { run_id });
918 })
920 .expect("failed to spawn snippet coordinator");
921}
922
923#[allow(clippy::too_many_arguments)]
928pub fn run_snippet(
929 alias: &str,
930 config_path: &Path,
931 env: &crate::runtime::env::Env,
932 command: &str,
933 askpass: Option<&str>,
934 bw_session: Option<&str>,
935 capture: bool,
936 has_active_tunnel: bool,
937) -> anyhow::Result<SnippetResult> {
938 crate::runtime::helpers::ensure_vault_cert_for_alias(env, alias, config_path);
942
943 let mut cmd = base_ssh_command(
944 alias,
945 config_path,
946 command,
947 askpass,
948 bw_session,
949 has_active_tunnel,
950 capture,
951 );
952
953 if capture {
954 cmd.stdin(Stdio::null())
955 .stdout(Stdio::piped())
956 .stderr(Stdio::piped());
957 } else {
958 cmd.stdin(Stdio::inherit())
959 .stdout(Stdio::inherit())
960 .stderr(Stdio::inherit());
961 }
962
963 if capture {
964 let (status, stdout, stderr) = run_with_bounded_output(&mut cmd, alias)?;
965 Ok(SnippetResult {
966 status,
967 stdout,
968 stderr,
969 })
970 } else {
971 let status = cmd
972 .status()
973 .map_err(|e| anyhow::anyhow!("Failed to run ssh for '{}': {}", alias, e))?;
974
975 Ok(SnippetResult {
976 status,
977 stdout: String::new(),
978 stderr: String::new(),
979 })
980 }
981}
982
983pub const SSH_OUTPUT_MAX_BYTES: usize = 16 * 1024 * 1024;
990
991fn run_with_bounded_output(
997 cmd: &mut Command,
998 alias: &str,
999) -> anyhow::Result<(ExitStatus, String, String)> {
1000 let mut child = cmd
1001 .spawn()
1002 .map_err(|e| anyhow::anyhow!("Failed to spawn ssh for '{}': {}", alias, e))?;
1003 let stdout = child.stdout.take();
1004 let stderr = child.stderr.take();
1005
1006 let alias_for_stdout = alias.to_string();
1007 let stdout_handle = std::thread::spawn(move || match stdout {
1008 Some(mut pipe) => {
1009 read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stdout, "stdout")
1010 }
1011 None => Vec::new(),
1012 });
1013 let alias_for_stderr = alias.to_string();
1014 let stderr_handle = std::thread::spawn(move || match stderr {
1015 Some(mut pipe) => {
1016 read_bounded(&mut pipe, SSH_OUTPUT_MAX_BYTES, &alias_for_stderr, "stderr")
1017 }
1018 None => Vec::new(),
1019 });
1020
1021 let stdout_bytes = stdout_handle.join().unwrap_or_default();
1029 let stderr_bytes = stderr_handle.join().unwrap_or_default();
1030 let status = child
1031 .wait()
1032 .map_err(|e| anyhow::anyhow!("ssh wait failed for '{}': {}", alias, e))?;
1033
1034 Ok((
1035 status,
1036 String::from_utf8_lossy(&stdout_bytes).to_string(),
1037 String::from_utf8_lossy(&stderr_bytes).to_string(),
1038 ))
1039}
1040
1041fn read_bounded<R: std::io::Read>(
1042 reader: &mut R,
1043 max: usize,
1044 alias: &str,
1045 stream: &str,
1046) -> Vec<u8> {
1047 let mut out = Vec::with_capacity(8 * 1024);
1048 let mut chunk = [0u8; 8 * 1024];
1049 loop {
1050 match reader.read(&mut chunk) {
1051 Ok(0) => break,
1052 Ok(n) => {
1053 if out.len() + n > max {
1054 let allowed = max.saturating_sub(out.len());
1055 out.extend_from_slice(&chunk[..allowed]);
1056 log::warn!(
1057 "[external] ssh {} for '{}' exceeded {} bytes; truncating remainder",
1058 stream,
1059 alias,
1060 max
1061 );
1062 let _ = std::io::copy(reader, &mut std::io::sink());
1065 break;
1066 }
1067 out.extend_from_slice(&chunk[..n]);
1068 }
1069 Err(e) => {
1070 log::debug!("[external] ssh {stream} read error for '{alias}': {e}");
1071 break;
1072 }
1073 }
1074 }
1075 out
1076}
1077
1078pub use crate::snippet_impact::{Category, CommandImpact, Finding, Severity, analyze_command};
1083
1084pub fn filtered_indices(store: &SnippetStore, query: Option<&str>) -> Vec<usize> {
1088 match query {
1089 None | Some("") => (0..store.snippets.len()).collect(),
1090 Some(q) => store
1091 .snippets
1092 .iter()
1093 .enumerate()
1094 .filter(|(_, s)| {
1095 crate::app::contains_ci(&s.name, q)
1096 || crate::app::contains_ci(&s.command, q)
1097 || crate::app::contains_ci(&s.description, q)
1098 })
1099 .map(|(i, _)| i)
1100 .collect(),
1101 }
1102}
1103
1104#[cfg(test)]
1105#[path = "snippet_tests.rs"]
1106mod tests;