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