From 5389ac97ac794131f57590b451f78137b1e80a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Nov 2026 02:01:56 +0100 Subject: [PATCH] Test Ctrl-C, job control and hangup with an interactive shell on a terminal --- tests/e2e/common.rs | 173 ++++++++++++++++++++++++- tests/e2e/forwarding.rs | 10 -- tests/e2e/main.rs | 1 + tests/e2e/signals.rs | 10 ++ tests/e2e/terminal.rs | 271 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/terminal.rs diff --git a/tests/e2e/common.rs b/tests/e2e/common.rs index 397aef6..92c285a 100644 --- a/tests/e2e/common.rs +++ b/tests/e2e/common.rs @@ -1,7 +1,13 @@ -use std::fs; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; use std::ops::{Deref, DerefMut}; -use std::process::Command; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Child, Command, Stdio}; use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use std::thread; +use std::time::{Duration, Instant}; use tempfile::TempDir; @@ -107,3 +113,166 @@ impl std::ops::Deref for ConfigFile { &self.path } } + +pub fn wait_for_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(10); + while !path.exists() { + assert!(Instant::now() < deadline, "timed out waiting for {path:?}"); + thread::sleep(Duration::from_millis(10)); + } +} + +pub struct Terminal { + master: Option, + output: String, + session_leader: Child, + _guard: RwLockReadGuard<'static, ()>, +} + +impl Terminal { + pub fn start(command_line: &[&str]) -> Self { + let guard = HostGlobsLock::for_scan(); + let (master, slave) = open_pty(); + + let mut command = Command::new(command_line[0]); + command + .args(&command_line[1..]) + .env("TERM", "dumb") + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave)); + unsafe { command.pre_exec(take_terminal) }; + let session_leader = command.spawn().expect("failed to start session leader"); + + Self { + master: Some(master), + output: String::new(), + session_leader, + _guard: guard, + } + } + + pub fn type_text(&mut self, text: &str) { + self.master().write_all(text.as_bytes()).unwrap(); + } + + pub fn type_line(&mut self, line: &str) { + self.type_text(&format!("{line}\n")); + } + + pub fn read_output(&mut self) -> &str { + self.read_available_output(); + &self.output + } + + pub fn wait_for_output(&mut self, text: &str) { + self.wait_until(&format!("output {text:?}"), |terminal| { + terminal.output.contains(text) + }); + } + + pub fn wait_until_shell_owns_terminal(&mut self) { + self.wait_until("shell in foreground", Self::is_shell_in_foreground); + } + + pub fn wait_until_job_owns_terminal(&mut self) { + self.wait_until("job in foreground", |terminal| { + !terminal.is_shell_in_foreground() + }); + } + + pub fn wait_until_job_stopped(&mut self) { + self.wait_until("job stopped", Self::has_stopped_job); + } + + pub fn hang_up(&mut self) { + self.master = None; + } + + fn master(&mut self) -> &mut File { + self.master.as_mut().expect("terminal is hung up") + } + + fn read_available_output(&mut self) { + let Some(master) = &mut self.master else { + return; + }; + let mut buffer = [0; 4096]; + while let Ok(count @ 1..) = master.read(&mut buffer) { + self.output + .push_str(&String::from_utf8_lossy(&buffer[..count])); + } + } + + fn wait_until(&mut self, description: &str, condition: impl Fn(&Self) -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + self.read_available_output(); + if condition(self) { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {description}, terminal output:\n{}", + self.output + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn is_shell_in_foreground(&self) -> bool { + let master = self.master.as_ref().expect("terminal is hung up"); + let foreground = unsafe { libc::tcgetpgrp(master.as_raw_fd()) }; + foreground == self.session_leader.id() as i32 + } + + fn has_stopped_job(&self) -> bool { + let session = self.session_leader.id().to_string(); + fs::read_dir("/proc") + .unwrap() + .filter_map(|entry| fs::read_to_string(entry.ok()?.path().join("stat")).ok()) + .any(|stat| { + let fields_after_comm = &stat[stat.rfind(')').unwrap() + 1..]; + let fields: Vec<&str> = fields_after_comm.split_whitespace().collect(); + fields[0] == "T" && fields[3] == session + }) + } +} + +fn open_pty() -> (File, OwnedFd) { + let flags = libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC; + let master = unsafe { libc::posix_openpt(flags | libc::O_NONBLOCK) }; + assert!( + master >= 0, + "posix_openpt failed: {}", + io::Error::last_os_error() + ); + let master = unsafe { File::from_raw_fd(master) }; + + assert_eq!( + unsafe { libc::unlockpt(master.as_raw_fd()) }, + 0, + "unlockpt failed" + ); + let slave = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGPTPEER, flags) }; + assert!( + slave >= 0, + "TIOCGPTPEER failed: {}", + io::Error::last_os_error() + ); + (master, unsafe { OwnedFd::from_raw_fd(slave) }) +} + +fn take_terminal() -> io::Result<()> { + if unsafe { libc::setsid() } == -1 || unsafe { libc::ioctl(0, libc::TIOCSCTTY, 0) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +impl Drop for Terminal { + fn drop(&mut self) { + let _ = self.session_leader.kill(); + let _ = self.session_leader.wait(); + } +} diff --git a/tests/e2e/forwarding.rs b/tests/e2e/forwarding.rs index 5c4651a..d97ed19 100644 --- a/tests/e2e/forwarding.rs +++ b/tests/e2e/forwarding.rs @@ -1,8 +1,6 @@ use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::path::Path; use std::process::{Child, Stdio}; -use std::thread; -use std::time::{Duration, Instant}; use tempfile::TempDir; @@ -125,14 +123,6 @@ fn spawn_sandbox(args: &[&str], dir: &Path, script: &str) -> (Sandbox, Child) { (sandbox, child) } -fn wait_for_file(path: &Path) { - let deadline = Instant::now() + Duration::from_secs(10); - while !path.exists() { - assert!(Instant::now() < deadline, "timed out waiting for {path:?}"); - thread::sleep(Duration::from_millis(10)); - } -} - fn send_signal(child: &Child, signal: i32) { unsafe { libc::kill(child.id() as i32, signal) }; } diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index 765e6bb..96d65d9 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -9,3 +9,4 @@ mod namespaces; mod sandbox_info; mod seccomp; mod signals; +mod terminal; diff --git a/tests/e2e/signals.rs b/tests/e2e/signals.rs index 104a810..75d3b40 100644 --- a/tests/e2e/signals.rs +++ b/tests/e2e/signals.rs @@ -18,6 +18,16 @@ fn exit_code_of_entrypoint_is_returned() { assert_eq!(status.code(), Some(42)); } +#[test] +fn explicit_exit_130_remains_an_exit_code() { + let status = Sandbox::new(&[]) + .args(["--", "sh", "-c", "exit 130"]) + .status() + .expect("agent-sandbox binary failed to execute"); + + assert_eq!(status.code(), Some(130)); +} + #[test] fn signal_death_of_entrypoint_becomes_exit_code() { let status = Sandbox::new(&[]) diff --git a/tests/e2e/terminal.rs b/tests/e2e/terminal.rs new file mode 100644 index 0000000..4dc6cd4 --- /dev/null +++ b/tests/e2e/terminal.rs @@ -0,0 +1,271 @@ +use std::fs; +use std::path::Path; +use std::thread; +use std::time::Duration; + +use tempfile::TempDir; + +use crate::common::*; + +const BASH: &[&str] = &["bash", "--norc", "--noprofile", "-i"]; +const ZSH: &[&str] = &["zsh", "-f", "-i"]; + +#[test] +fn ctrl_c_lets_entrypoint_clean_up_in_bash() { + assert_ctrl_c_lets_entrypoint_clean_up(BASH); +} + +#[test] +fn ctrl_c_lets_entrypoint_clean_up_in_zsh() { + assert_ctrl_c_lets_entrypoint_clean_up(ZSH); +} + +fn assert_ctrl_c_lets_entrypoint_clean_up(shell: &[&str]) { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = "trap 'sleep 0.5; touch cleaned; exit 0' INT + touch ready + sleep 60 & wait"; + + let mut terminal = Terminal::start(shell); + terminal.type_line(&build_sandbox_command(dir.path(), script)); + wait_for_file(&dir.path().join("ready")); + terminal.wait_until_job_owns_terminal(); + terminal.type_text(CTRL_C); + terminal.type_line("echo STATUS-$?"); + terminal.wait_for_output("STATUS-0"); + + assert!( + dir.path().join("cleaned").exists(), + "cleanup did not finish" + ); +} + +const CTRL_C: &str = "\x03"; +const CTRL_Z: &str = "\x1a"; + +fn build_sandbox_command(dir: &Path, script: &str) -> String { + let args = build_sandbox_args(dir, script); + shlex::try_join(args.iter().map(String::as_str)).unwrap() +} + +fn build_sandbox_args(dir: &Path, script: &str) -> Vec { + let dir = dir.to_str().unwrap(); + [ + env!("CARGO_BIN_EXE_agent-sandbox"), + "--no-config", + "--blacklist", + "--chdir", + dir, + "--", + "sh", + "-c", + script, + ] + .map(String::from) + .to_vec() +} + +#[test] +fn ctrl_z_and_fg_resume_entrypoint_in_bash() { + assert_ctrl_z_and_fg_resume_entrypoint(BASH); +} + +#[test] +fn ctrl_z_and_fg_resume_entrypoint_in_zsh() { + assert_ctrl_z_and_fg_resume_entrypoint(ZSH); +} + +fn assert_ctrl_z_and_fg_resume_entrypoint(shell: &[&str]) { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = "touch ready + read line + echo \"got-$line\""; + + let mut terminal = Terminal::start(shell); + terminal.type_line(&build_sandbox_command(dir.path(), script)); + wait_for_file(&dir.path().join("ready")); + terminal.wait_until_job_owns_terminal(); + terminal.type_text(CTRL_Z); + terminal.wait_until_shell_owns_terminal(); + terminal.type_line("fg"); + terminal.wait_until_job_owns_terminal(); + terminal.type_line("hello"); + terminal.wait_for_output("got-hello"); + terminal.type_line("echo STATUS-$?"); + + terminal.wait_for_output("STATUS-0"); +} + +#[test] +fn ctrl_c_ends_surrounding_bash_script() { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = ": > ready + sleep 60"; + let surrounding_script = format!( + "{}; echo AFTER-$((1 + 1))", + build_sandbox_command(dir.path(), script) + ); + + let mut terminal = Terminal::start(BASH); + terminal.type_line(&format!( + "bash -c {}", + shlex::try_quote(&surrounding_script).unwrap() + )); + wait_for_file(&dir.path().join("ready")); + terminal.wait_until_job_owns_terminal(); + terminal.type_text(CTRL_C); + terminal.type_line("echo STATUS-$?"); + terminal.wait_for_output("STATUS-130"); + + assert!( + !terminal.read_output().contains("AFTER-2"), + "surrounding script continued after Ctrl-C" + ); +} + +#[test] +fn ctrl_z_and_bg_continue_entrypoint_in_bash() { + assert_ctrl_z_and_bg_continue_entrypoint(BASH); +} + +#[test] +fn ctrl_z_and_bg_continue_entrypoint_in_zsh() { + assert_ctrl_z_and_bg_continue_entrypoint(ZSH); +} + +fn assert_ctrl_z_and_bg_continue_entrypoint(shell: &[&str]) { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = "touch ready + while [ ! -e go ]; do sleep 0.01; done + touch done"; + + let mut terminal = Terminal::start(shell); + terminal.type_line(&build_sandbox_command(dir.path(), script)); + wait_for_file(&dir.path().join("ready")); + terminal.wait_until_job_owns_terminal(); + terminal.type_text(CTRL_Z); + terminal.wait_until_shell_owns_terminal(); + fs::write(dir.path().join("go"), "").unwrap(); + thread::sleep(Duration::from_millis(200)); + assert!(!dir.path().join("done").exists(), "entrypoint did not stop"); + terminal.type_line("bg"); + + wait_for_file(&dir.path().join("done")); +} + +#[test] +fn background_job_reads_terminal_after_fg_in_bash() { + assert_background_job_reads_terminal_after_fg(BASH); +} + +#[test] +fn background_job_reads_terminal_after_fg_in_zsh() { + assert_background_job_reads_terminal_after_fg(ZSH); +} + +fn assert_background_job_reads_terminal_after_fg(shell: &[&str]) { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = "touch ready + read line + echo \"got-$line\""; + + let mut terminal = Terminal::start(shell); + terminal.type_line(&format!("{} &", build_sandbox_command(dir.path(), script))); + wait_for_file(&dir.path().join("ready")); + terminal.wait_until_job_stopped(); + terminal.type_line("fg"); + terminal.wait_until_job_owns_terminal(); + terminal.type_line("hello"); + terminal.wait_for_output("got-hello"); + terminal.type_line("echo STATUS-$?"); + + terminal.wait_for_output("STATUS-0"); +} + +#[test] +fn ctrl_c_reaches_both_sandboxes_in_a_pipeline() { + let first = TempDir::new().expect("failed to create temp dir"); + let second = TempDir::new().expect("failed to create temp dir"); + let script = "trap 'touch cleaned; exit 0' INT + touch ready + sleep 60 & wait"; + + let mut terminal = Terminal::start(BASH); + terminal.type_line(&format!( + "{} | {}", + build_sandbox_command(first.path(), script), + build_sandbox_command(second.path(), script) + )); + wait_for_file(&first.path().join("ready")); + wait_for_file(&second.path().join("ready")); + terminal.wait_until_job_owns_terminal(); + terminal.type_text(CTRL_C); + terminal.type_line("echo STATUS-$?"); + terminal.wait_for_output("STATUS-0"); + + assert!( + first.path().join("cleaned").exists(), + "first sandbox did not clean up" + ); + assert!( + second.path().join("cleaned").exists(), + "second sandbox did not clean up" + ); +} + +#[test] +fn hangup_of_session_leader_reaches_entrypoint() { + let dir = TempDir::new().expect("failed to create temp dir"); + let args = build_sandbox_args(dir.path(), HANGUP_SCRIPT); + + let mut terminal = Terminal::start(&args.iter().map(String::as_str).collect::>()); + wait_for_file(&dir.path().join("ready")); + terminal.hang_up(); + + wait_for_file(&dir.path().join("hungup")); +} + +const HANGUP_SCRIPT: &str = "trap 'touch hungup; exit 0' HUP + touch ready + sleep 60 & wait"; + +#[test] +fn shell_hangup_reaches_entrypoint_in_bash() { + assert_shell_hangup_reaches_entrypoint(BASH); +} + +#[test] +fn shell_hangup_reaches_entrypoint_in_zsh() { + assert_shell_hangup_reaches_entrypoint(ZSH); +} + +fn assert_shell_hangup_reaches_entrypoint(shell: &[&str]) { + let dir = TempDir::new().expect("failed to create temp dir"); + + let mut terminal = Terminal::start(shell); + terminal.type_line(&build_sandbox_command(dir.path(), HANGUP_SCRIPT)); + wait_for_file(&dir.path().join("ready")); + terminal.hang_up(); + + wait_for_file(&dir.path().join("hungup")); +} + +#[test] +fn nohup_keeps_entrypoint_alive_after_hangup() { + let dir = TempDir::new().expect("failed to create temp dir"); + let script = "touch ready + while [ ! -e go ]; do sleep 0.01; done + touch survived"; + + let mut terminal = Terminal::start(BASH); + terminal.type_line(&format!( + "nohup {} >/dev/null 2>&1", + build_sandbox_command(dir.path(), script) + )); + wait_for_file(&dir.path().join("ready")); + terminal.hang_up(); + thread::sleep(Duration::from_millis(200)); + fs::write(dir.path().join("go"), "").unwrap(); + + wait_for_file(&dir.path().join("survived")); +}