Files
codex/codex-rs/utils/pty/src/command_validation_tests.rs
T
Charlie Marsh 50fc133d05 Route pipe processes through the shared child launcher (#47605)
## Why

Pipe spawning installs Unix `pre_exec` hooks, forcing a fork on macOS even though the shared child launcher supports native spawning.

## What changed

- Use the shared `Command` for pipe processes, enabling native macOS spawning while preserving session isolation, explicit descriptor inheritance, and Linux parent-death handling.
- Select `ReapOnly` so the pipe adapter retains ownership of process-tree termination.
- Track embedded NUL bytes in original Unix command inputs and reject them before spawning, including when invalid `current_dir` or `arg0` values are later replaced.

## Testing

Add macOS integration coverage verifying fork avoidance, executable path lookup, pipe I/O, session isolation, and descriptor inheritance. Add Unix regression tests for pipe and PTY fallback under descriptor pressure and for rejecting NUL inputs without running the child.

GitOrigin-RevId: f4fa2c1d3c1aee2c4ffa1dc2ff946f9f0b08eb20
2026-09-23 18:00:11 +00:00

127 lines
3.6 KiB
Rust

//! Invalid command inputs must fail before a child can run on either backend.
use std::io;
use pretty_assertions::assert_eq;
use crate::Command;
use crate::ProcessMode;
enum Input {
Program,
Arg,
Args,
Cwd,
Arg0,
EnvKey,
EnvValue,
}
#[tokio::test]
async fn embedded_nul_is_rejected_before_spawning() -> anyhow::Result<()> {
for mode in [ProcessMode::Inherit, ProcessMode::NewSession] {
for input in [
Input::Program,
Input::Arg,
Input::Args,
Input::Cwd,
Input::Arg0,
Input::EnvKey,
Input::EnvValue,
] {
let root = tempfile::tempdir()?;
let marker = root.path().join("ran");
let mut command = Command::new(match input {
Input::Program => "/bin/sh\0",
Input::Arg
| Input::Args
| Input::Cwd
| Input::Arg0
| Input::EnvKey
| Input::EnvValue => "/bin/sh",
});
command
.process_mode(mode)
.args(["-c", "printf ran > \"$1\"", "sh"])
.arg(&marker);
match input {
Input::Program => {}
Input::Arg => {
command.arg("bad\0arg");
}
Input::Args => {
command.args(["bad\0arg"]);
}
Input::Cwd => {
command.current_dir("bad\0cwd").current_dir(root.path());
}
Input::Arg0 => {
command.arg0("bad\0arg0").arg0("sh");
}
Input::EnvKey => {
command.env("bad\0key", "value");
}
Input::EnvValue => {
command.env("key", "bad\0value");
}
}
let error = match command.spawn() {
Err(error) => error,
Ok(child) => {
let output = child.wait_with_output().await?;
anyhow::bail!(
"invalid command ran: {output:?}, marker={}",
marker.exists()
);
}
};
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(!marker.exists());
}
}
Ok(())
}
#[tokio::test]
async fn literal_nul_placeholder_is_a_valid_argument() -> anyhow::Result<()> {
let mut command = Command::new("/bin/echo");
command
.process_mode(ProcessMode::NewSession)
.arg("<string-with-nul>");
let output = command.spawn()?.wait_with_output().await?;
assert!(output.status.success());
assert_eq!(output.stdout, b"<string-with-nul>\n");
Ok(())
}
#[tokio::test]
async fn pipe_rejects_nul_arguments_without_side_effects() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let marker = root.path().join("ran");
let result = crate::spawn_pipe_process(
"/usr/bin/touch",
&[
marker.to_string_lossy().into_owned(),
"bad\0arg".to_string(),
],
root.path(),
&std::collections::HashMap::new(),
/*arg0*/ &None,
&[],
)
.await;
let error = match result {
Err(error) => error,
Ok(child) => {
let _ = child.exit_rx.await;
anyhow::bail!("invalid pipe command ran, marker={}", marker.exists());
}
};
assert_eq!(
error.downcast_ref::<io::Error>().map(io::Error::kind),
Some(io::ErrorKind::InvalidInput)
);
assert!(!marker.exists());
Ok(())
}