1use std::env;
10use std::path::PathBuf;
11use std::process::{Command, Stdio};
12
13use crate::args_parser::generate_session_name;
14
15#[derive(Debug, Default)]
17pub struct IsolationResult {
18 pub success: bool,
20 pub session_name: Option<String>,
22 pub container_id: Option<String>,
24 pub message: String,
26 pub exit_code: Option<i32>,
28 pub output: Option<String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct IsolationOptions {
35 pub session: Option<String>,
37 pub image: Option<String>,
39 pub endpoint: Option<String>,
41 pub detached: bool,
43 pub user: Option<String>,
45 pub keep_alive: bool,
47 pub auto_remove_docker_container: bool,
49 pub shell: String,
51 pub log_path: Option<PathBuf>,
53}
54
55impl Default for IsolationOptions {
56 fn default() -> Self {
57 IsolationOptions {
58 session: None,
59 image: None,
60 endpoint: None,
61 detached: false,
62 user: None,
63 keep_alive: false,
64 auto_remove_docker_container: false,
65 shell: "auto".to_string(),
66 log_path: None,
67 }
68 }
69}
70
71pub fn is_command_available(command: &str) -> bool {
73 let check_cmd = if cfg!(windows) { "where" } else { "which" };
74 Command::new(check_cmd)
75 .arg(command)
76 .stdout(Stdio::null())
77 .stderr(Stdio::null())
78 .status()
79 .map(|s| s.success())
80 .unwrap_or(false)
81}
82
83pub fn get_shell() -> (String, String) {
85 if cfg!(windows) {
86 ("cmd.exe".to_string(), "/c".to_string())
87 } else {
88 let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
89 (shell, "-c".to_string())
90 }
91}
92
93pub fn has_tty() -> bool {
95 atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
96}
97
98pub fn wrap_command_with_user(command: &str, user: Option<&str>) -> String {
100 match user {
101 Some(u) => {
102 let escaped = command.replace('\'', "'\\''");
104 format!("sudo -n -u {} sh -c '{}'", u, escaped)
105 }
106 None => command.to_string(),
107 }
108}
109
110const SHELL_NAMES: [&str; 8] = ["bash", "zsh", "sh", "fish", "ksh", "csh", "tcsh", "dash"];
113
114pub fn is_interactive_shell_command(command: &str) -> bool {
120 let parts: Vec<&str> = command.split_whitespace().collect();
121 if parts.is_empty() {
122 return false;
123 }
124 let basename = parts[0].rsplit('/').next().unwrap_or(parts[0]);
125 SHELL_NAMES.contains(&basename) && !parts.contains(&"-c")
126}
127
128pub fn is_shell_invocation_with_args(command: &str) -> bool {
131 let parts: Vec<&str> = command.split_whitespace().collect();
132 if parts.is_empty() {
133 return false;
134 }
135 let basename = parts[0].rsplit('/').next().unwrap_or(parts[0]);
136 SHELL_NAMES.contains(&basename) && parts.contains(&"-c")
137}
138
139pub fn build_shell_with_args_cmd_args(command: &str) -> Vec<String> {
143 let parts: Vec<&str> = command.split_whitespace().collect();
144 let c_idx = parts.iter().position(|&p| p == "-c");
145 match c_idx {
146 None => parts.iter().map(|s| s.to_string()).collect(),
147 Some(idx) => {
148 let script_arg = parts[idx + 1..].join(" ");
149 let mut result: Vec<String> = parts[..idx + 1].iter().map(|s| s.to_string()).collect();
150 if !script_arg.is_empty() {
151 result.push(script_arg);
152 }
153 result
154 }
155 }
156}
157
158fn get_shell_interactive_flag(shell_path: &str) -> Option<&'static str> {
160 let shell_name = shell_path.rsplit('/').next().unwrap_or(shell_path);
161 match shell_name {
162 "bash" => Some("-i"),
163 "zsh" => Some("-i"),
164 _ => None,
165 }
166}
167
168pub fn detect_shell_in_environment(environment: &str, options: &IsolationOptions) -> String {
172 let shell_preference = &options.shell;
173
174 if !shell_preference.is_empty() && shell_preference != "auto" {
176 if is_debug() {
177 eprintln!("[DEBUG] Using forced shell: {}", shell_preference);
178 }
179 return shell_preference.clone();
180 }
181
182 let shells_to_try = ["bash", "zsh", "sh"];
184
185 if environment == "docker" {
186 let image = match &options.image {
187 Some(i) => i.clone(),
188 None => return "sh".to_string(),
189 };
190
191 for shell in &shells_to_try {
192 let result = Command::new("docker")
193 .args([
194 "run",
195 "--rm",
196 &image,
197 "sh",
198 "-c",
199 &format!("command -v {}", shell),
200 ])
201 .stdout(Stdio::piped())
202 .stderr(Stdio::null())
203 .output();
204
205 if let Ok(output) = result {
206 if output.status.success() {
207 let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
208 if !detected.is_empty() {
209 if is_debug() {
210 eprintln!(
211 "[DEBUG] Detected shell in docker image {}: {}",
212 image, detected
213 );
214 }
215 return detected;
216 }
217 }
218 }
219 }
220
221 if is_debug() {
222 eprintln!(
223 "[DEBUG] Could not detect shell in docker image {}, falling back to sh",
224 image
225 );
226 }
227 return "sh".to_string();
228 }
229
230 if environment == "ssh" {
231 let endpoint = match &options.endpoint {
232 Some(e) => e.clone(),
233 None => return "sh".to_string(),
234 };
235
236 let check_cmd: Vec<String> = shells_to_try
238 .iter()
239 .map(|s| format!("command -v {}", s))
240 .collect();
241 let check_cmd_str = check_cmd.join(" || ");
242
243 let result = Command::new("ssh")
244 .args([&endpoint, &check_cmd_str])
245 .stdout(Stdio::piped())
246 .stderr(Stdio::null())
247 .output();
248
249 if let Ok(output) = result {
250 if output.status.success() {
251 let detected = String::from_utf8_lossy(&output.stdout).trim().to_string();
252 if !detected.is_empty() {
253 if is_debug() {
254 eprintln!(
255 "[DEBUG] Detected shell on SSH host {}: {}",
256 endpoint, detected
257 );
258 }
259 return detected;
260 }
261 }
262 }
263
264 if is_debug() {
265 eprintln!(
266 "[DEBUG] Could not detect shell on SSH host {}, falling back to sh",
267 endpoint
268 );
269 }
270 return "sh".to_string();
271 }
272
273 "sh".to_string()
274}
275
276#[path = "isolation_screen.rs"]
277pub mod isolation_screen;
278pub use self::isolation_screen::{get_screen_version, supports_logfile_option};
279
280pub fn run_in_screen(command: &str, options: &IsolationOptions) -> IsolationResult {
282 if !is_command_available("screen") {
283 return IsolationResult {
284 success: false,
285 message: "screen is not installed. Install it with: sudo apt-get install screen (Debian/Ubuntu) or brew install screen (macOS)".to_string(),
286 ..Default::default()
287 };
288 }
289
290 let session_name = options
291 .session
292 .clone()
293 .unwrap_or_else(|| generate_session_name(Some("screen")));
294
295 if options.detached {
296 isolation_screen::start_detached_screen_with_log_capture(
297 command,
298 &session_name,
299 options.user.as_deref(),
300 options.keep_alive,
301 options.log_path.as_deref(),
302 )
303 } else {
304 isolation_screen::run_screen_with_log_capture(
306 command,
307 &session_name,
308 options.user.as_deref(),
309 options.log_path.as_deref(),
310 )
311 }
312}
313
314pub fn run_in_tmux(command: &str, options: &IsolationOptions) -> IsolationResult {
316 if !is_command_available("tmux") {
317 return IsolationResult {
318 success: false,
319 message: "tmux is not installed. Install it with: sudo apt-get install tmux (Debian/Ubuntu) or brew install tmux (macOS)".to_string(),
320 ..Default::default()
321 };
322 }
323
324 let session_name = options
325 .session
326 .clone()
327 .unwrap_or_else(|| generate_session_name(Some("tmux")));
328
329 let (shell, _) = get_shell();
330 let effective_command = wrap_command_with_user(command, options.user.as_deref());
331
332 if options.detached {
333 let final_command = if options.log_path.is_some() {
334 crate::isolation::isolation_log::wrap_command_with_log_footer(
335 &effective_command,
336 &shell,
337 options.keep_alive,
338 )
339 } else if options.keep_alive {
340 format!("{}; exec {}", effective_command, shell)
341 } else {
342 effective_command.clone()
343 };
344
345 let status = if let Some(log_path) = options.log_path.as_ref() {
346 let start_status = Command::new("tmux")
347 .args(["new-session", "-d", "-s", &session_name, &shell])
348 .status();
349 if start_status.as_ref().is_ok_and(|s| s.success()) {
350 let pipe_command = format!(
351 "cat >> {}",
352 crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy())
353 );
354 let _ = Command::new("tmux")
355 .args(["pipe-pane", "-t", &session_name, "-o", &pipe_command])
356 .status();
357 Command::new("tmux")
358 .args(["send-keys", "-t", &session_name, &final_command, "C-m"])
359 .status()
360 } else {
361 start_status
362 }
363 } else {
364 Command::new("tmux")
365 .args(["new-session", "-d", "-s", &session_name, &final_command])
366 .status()
367 };
368
369 match status {
370 Ok(s) if s.success() => {
371 let mut message =
372 format!("Command started in detached tmux session: {}", session_name);
373 if options.keep_alive {
374 message.push_str("\nSession will stay alive after command completes.");
375 } else {
376 message.push_str("\nSession will exit automatically after command completes.");
377 }
378 message.push_str(&format!("\nReattach with: tmux attach -t {}", session_name));
379 if let Some(log_path) = options.log_path.as_ref() {
380 message.push_str(&format!("\nLive log: {}", log_path.display()));
381 }
382
383 IsolationResult {
384 success: true,
385 session_name: Some(session_name),
386 message,
387 ..Default::default()
388 }
389 }
390 _ => IsolationResult {
391 success: false,
392 session_name: Some(session_name),
393 message: "Failed to start tmux session".to_string(),
394 ..Default::default()
395 },
396 }
397 } else {
398 let output = Command::new("tmux")
400 .args(["new-session", "-s", &session_name, &effective_command])
401 .status();
402
403 match output {
404 Ok(status) => IsolationResult {
405 success: status.success(),
406 session_name: Some(session_name.clone()),
407 message: format!(
408 "Tmux session \"{}\" exited with code {}",
409 session_name,
410 status.code().unwrap_or(-1)
411 ),
412 exit_code: status.code(),
413 ..Default::default()
414 },
415 Err(e) => IsolationResult {
416 success: false,
417 session_name: Some(session_name),
418 message: format!("Failed to start tmux: {}", e),
419 ..Default::default()
420 },
421 }
422 }
423}
424
425pub fn run_in_ssh(command: &str, options: &IsolationOptions) -> IsolationResult {
427 if !is_command_available("ssh") {
428 return IsolationResult {
429 success: false,
430 message: "ssh is not installed".to_string(),
431 ..Default::default()
432 };
433 }
434
435 let endpoint = match &options.endpoint {
436 Some(e) => e.clone(),
437 None => {
438 return IsolationResult {
439 success: false,
440 message: "SSH isolation requires --endpoint option".to_string(),
441 ..Default::default()
442 };
443 }
444 };
445
446 let session_name = options
447 .session
448 .clone()
449 .unwrap_or_else(|| generate_session_name(Some("ssh")));
450
451 let shell_to_use = detect_shell_in_environment("ssh", options);
453 let shell_interactive_flag = get_shell_interactive_flag(&shell_to_use);
456
457 if options.detached {
458 let shell_invocation = if let Some(flag) = shell_interactive_flag {
461 format!("{} {}", shell_to_use, flag)
462 } else {
463 shell_to_use.clone()
464 };
465 let remote_command = format!(
466 "mkdir -p /tmp/start-command/logs/isolation/ssh && nohup {} -c {} > /tmp/start-command/logs/isolation/ssh/{}.log 2>&1 &",
467 shell_invocation,
468 shell_escape(command),
469 session_name
470 );
471 let ssh_args = vec![endpoint.as_str(), remote_command.as_str()];
472
473 if is_debug() {
474 eprintln!("[DEBUG] Running: ssh {:?}", ssh_args);
475 eprintln!("[DEBUG] shell: {}", shell_invocation);
476 }
477
478 let status = Command::new("ssh").args(&ssh_args).status();
479
480 match status {
481 Ok(s) if s.success() => IsolationResult {
482 success: true,
483 session_name: Some(session_name.clone()),
484 message: format!(
485 "Command started in detached SSH session on {}\nSession: {}\nView logs: ssh {} \"tail -f /tmp/start-command/logs/isolation/ssh/{}.log\"",
486 endpoint, session_name, endpoint, session_name
487 ),
488 ..Default::default()
489 },
490 _ => IsolationResult {
491 success: false,
492 session_name: Some(session_name),
493 message: "Failed to start SSH session".to_string(),
494 ..Default::default()
495 },
496 }
497 } else {
498 let mut ssh_cmd_args = vec![endpoint.clone(), shell_to_use.clone()];
501 if let Some(flag) = shell_interactive_flag {
502 ssh_cmd_args.push(flag.to_string());
503 }
504 ssh_cmd_args.push("-c".to_string());
505 ssh_cmd_args.push(command.to_string());
506
507 if is_debug() {
508 eprintln!("[DEBUG] Running: ssh {:?}", ssh_cmd_args);
509 eprintln!("[DEBUG] shell: {}", shell_to_use);
510 }
511
512 let status = Command::new("ssh").args(&ssh_cmd_args).status();
513
514 match status {
515 Ok(s) => IsolationResult {
516 success: s.success(),
517 session_name: Some(session_name.clone()),
518 message: format!(
519 "SSH session \"{}\" on {} exited with code {}",
520 session_name,
521 endpoint,
522 s.code().unwrap_or(-1)
523 ),
524 exit_code: s.code(),
525 ..Default::default()
526 },
527 Err(e) => IsolationResult {
528 success: false,
529 session_name: Some(session_name),
530 message: format!("Failed to start SSH: {}", e),
531 ..Default::default()
532 },
533 }
534 }
535}
536
537pub fn docker_image_exists(image: &str) -> bool {
539 Command::new("docker")
540 .args(["image", "inspect", image])
541 .stdout(Stdio::null())
542 .stderr(Stdio::null())
543 .status()
544 .map(|s| s.success())
545 .unwrap_or(false)
546}
547
548pub fn docker_pull_image(image: &str) -> (bool, String) {
551 use std::io::{BufRead, BufReader};
552
553 println!(
555 "{}",
556 crate::output_blocks::create_virtual_command_block(&format!("docker pull {}", image))
557 );
558 println!();
559
560 let mut child = match Command::new("docker")
561 .args(["pull", image])
562 .stdout(Stdio::piped())
563 .stderr(Stdio::piped())
564 .spawn()
565 {
566 Ok(c) => c,
567 Err(e) => {
568 let error_msg = format!("Failed to run docker pull: {}", e);
569 eprintln!("{}", error_msg);
570 println!();
571 println!(
572 "{}",
573 crate::output_blocks::create_virtual_command_result(false)
574 );
575 return (false, error_msg);
576 }
577 };
578
579 let mut output = String::new();
580
581 if let Some(stdout) = child.stdout.take() {
583 let reader = BufReader::new(stdout);
584 for line in reader.lines().map_while(Result::ok) {
585 println!("{}", line);
586 output.push_str(&line);
587 output.push('\n');
588 }
589 }
590
591 if let Some(stderr) = child.stderr.take() {
593 let reader = BufReader::new(stderr);
594 for line in reader.lines().map_while(Result::ok) {
595 eprintln!("{}", line);
596 output.push_str(&line);
597 output.push('\n');
598 }
599 }
600
601 let success = child.wait().map(|s| s.success()).unwrap_or(false);
602
603 println!();
606 println!(
607 "{}",
608 crate::output_blocks::create_virtual_command_result(success)
609 );
610 println!("{}", crate::output_blocks::create_timeline_separator());
611
612 (success, output)
613}
614
615pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult {
617 if !is_command_available("docker") {
618 return IsolationResult {
619 success: false,
620 message:
621 "docker is not installed. Install Docker from https://docs.docker.com/get-docker/"
622 .to_string(),
623 ..Default::default()
624 };
625 }
626
627 let image = match &options.image {
628 Some(i) => i.clone(),
629 None => {
630 return IsolationResult {
631 success: false,
632 message: "Docker isolation requires --image option".to_string(),
633 ..Default::default()
634 };
635 }
636 };
637
638 if !docker_image_exists(&image) {
640 let (pull_success, _pull_output) = docker_pull_image(&image);
641 if !pull_success {
642 return IsolationResult {
643 success: false,
644 message: format!("Failed to pull Docker image: {}", image),
645 exit_code: Some(1),
646 ..Default::default()
647 };
648 }
649 }
650
651 let container_name = options
652 .session
653 .clone()
654 .unwrap_or_else(|| generate_session_name(Some("docker")));
655
656 let shell_to_use = detect_shell_in_environment("docker", options);
658 let shell_interactive_flag = get_shell_interactive_flag(&shell_to_use);
661
662 println!("{}", crate::output_blocks::create_command_line(command));
664 println!();
665
666 if options.detached {
667 let effective_command = if options.keep_alive {
668 format!("{}; exec {}", command, shell_to_use)
669 } else {
670 command.to_string()
671 };
672
673 let mut args = vec!["run", "-d", "--name", &container_name];
674
675 if options.auto_remove_docker_container {
676 args.push("--rm");
677 }
678
679 if let Some(ref user) = options.user {
680 args.push("--user");
681 args.push(user);
682 }
683
684 args.push(&image);
685 args.push(&shell_to_use);
686 if let Some(flag) = shell_interactive_flag {
687 args.push(flag);
688 }
689 args.extend(&["-c", &effective_command]);
690
691 if is_debug() {
692 eprintln!("[DEBUG] Running: docker {:?}", args);
693 eprintln!("[DEBUG] shell: {}", shell_to_use);
694 }
695
696 match Command::new("docker").args(&args).output() {
697 Ok(output) if output.status.success() => {
698 let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
699
700 if let Some(log_path) = options.log_path.as_ref() {
701 let logger_script = format!(
702 "docker logs -f {} >> {} 2>&1; __start_command_exit=$(docker inspect -f '{{{{.State.ExitCode}}}}' {} 2>/dev/null || printf '%s' '-1'); {} >> {}",
703 crate::isolation::isolation_log::shell_quote(&container_name),
704 crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy()),
705 crate::isolation::isolation_log::shell_quote(&container_name),
706 crate::isolation::isolation_log::create_shell_log_footer_snippet(),
707 crate::isolation::isolation_log::shell_quote(&log_path.to_string_lossy())
708 );
709 let _ = Command::new("sh")
710 .args(["-c", &logger_script])
711 .stdout(Stdio::null())
712 .stderr(Stdio::null())
713 .spawn();
714 }
715
716 let mut message = format!(
717 "Command started in detached docker container: {}",
718 container_name
719 );
720 message.push_str(&format!(
721 "\nContainer ID: {}",
722 &container_id[..12.min(container_id.len())]
723 ));
724 if options.keep_alive {
725 message.push_str("\nContainer will stay alive after command completes.");
726 } else {
727 message
728 .push_str("\nContainer will exit automatically after command completes.");
729 }
730 if options.auto_remove_docker_container {
731 message.push_str("\nContainer will be automatically removed after exit.");
732 } else {
733 message.push_str("\nContainer filesystem will be preserved after exit.");
734 }
735 message.push_str(&format!("\nAttach with: docker attach {}", container_name));
736 message.push_str(&format!("\nView logs: docker logs {}", container_name));
737 if let Some(log_path) = options.log_path.as_ref() {
738 message.push_str(&format!("\nLive log: {}", log_path.display()));
739 }
740
741 IsolationResult {
742 success: true,
743 session_name: Some(container_name),
744 container_id: Some(container_id),
745 message,
746 ..Default::default()
747 }
748 }
749 Ok(output) => {
750 let stderr = String::from_utf8_lossy(&output.stderr);
751 IsolationResult {
752 success: false,
753 session_name: Some(container_name),
754 message: format!("Failed to start docker container: {}", stderr),
755 ..Default::default()
756 }
757 }
758 Err(e) => IsolationResult {
759 success: false,
760 session_name: Some(container_name),
761 message: format!("Failed to run docker: {}", e),
762 ..Default::default()
763 },
764 }
765 } else {
766 let mut args = vec!["run", "-it", "--rm", "--name", &container_name];
768
769 if let Some(ref user) = options.user {
770 args.push("--user");
771 args.push(user);
772 }
773
774 if is_debug() {
775 eprintln!("[DEBUG] shell: {}", shell_to_use);
776 }
777
778 args.push(&image);
779 args.push(&shell_to_use);
780 if let Some(flag) = shell_interactive_flag {
781 args.push(flag);
782 }
783 args.extend(&["-c", command]);
784
785 let status = Command::new("docker").args(&args).status();
786
787 match status {
788 Ok(s) => IsolationResult {
789 success: s.success(),
790 session_name: Some(container_name.clone()),
791 message: format!(
792 "Docker container \"{}\" exited with code {}",
793 container_name,
794 s.code().unwrap_or(-1)
795 ),
796 exit_code: s.code(),
797 ..Default::default()
798 },
799 Err(e) => IsolationResult {
800 success: false,
801 session_name: Some(container_name),
802 message: format!("Failed to start docker: {}", e),
803 ..Default::default()
804 },
805 }
806 }
807}
808
809pub fn run_isolated(backend: &str, command: &str, options: &IsolationOptions) -> IsolationResult {
811 match backend {
812 "screen" => run_in_screen(command, options),
813 "tmux" => run_in_tmux(command, options),
814 "docker" => run_in_docker(command, options),
815 "ssh" => run_in_ssh(command, options),
816 _ => IsolationResult {
817 success: false,
818 message: format!("Unknown isolation backend: {}", backend),
819 ..Default::default()
820 },
821 }
822}
823
824pub fn run_as_isolated_user(command: &str, username: &str) -> IsolationResult {
826 let status = Command::new("sudo")
827 .args(["-n", "-u", username, "sh", "-c", command])
828 .status();
829
830 match status {
831 Ok(s) => IsolationResult {
832 success: s.success(),
833 message: format!(
834 "Command completed as user \"{}\" with exit code {}",
835 username,
836 s.code().unwrap_or(-1)
837 ),
838 exit_code: s.code(),
839 ..Default::default()
840 },
841 Err(e) => IsolationResult {
842 success: false,
843 message: format!("Failed to run as user \"{}\": {}", username, e),
844 exit_code: Some(1),
845 ..Default::default()
846 },
847 }
848}
849
850#[path = "isolation_log.rs"]
851pub mod isolation_log;
852pub use self::isolation_log::{
853 append_log_file, create_log_footer, create_log_header, create_log_path,
854 create_log_path_for_execution, generate_log_filename, get_default_docker_image, get_log_dir,
855 get_temp_dir, get_temp_root, get_timestamp, write_log_file, LogHeaderParams,
856};
857
858fn is_debug() -> bool {
859 env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true")
860}
861
862fn shell_escape(command: &str) -> String {
863 format!("'{}'", command.replace('\'', "'\\''"))
864}
865
866#[path = "atty.rs"]
867mod atty;
868
869#[cfg(test)]
870#[path = "isolation_cases.rs"]
871mod tests;