Isolate interactive terminal session ownership

This commit is contained in:
ms2k 2026-08-01 00:00:47 +09:00
commit 690738d846
4 changed files with 280 additions and 24 deletions

View file

@ -33,6 +33,7 @@ pub struct TargetCommand {
parent_edge: ParentEdge,
executable: Option<OwnedFd>,
stdin: Option<File>,
terminal: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -106,6 +107,7 @@ impl TargetCommand {
parent_edge: ParentEdge::ParentDeathKill,
executable: None,
stdin: None,
terminal: false,
}
}
@ -124,6 +126,7 @@ impl TargetCommand {
parent_edge: ParentEdge::ParentDeathKill,
executable: Some(executable),
stdin: None,
terminal: false,
})
}
@ -156,6 +159,12 @@ impl TargetCommand {
Ok(self)
}
#[must_use]
pub fn with_controlling_terminal(mut self) -> Self {
self.terminal = true;
self
}
#[must_use]
pub const fn parent_edge(&self) -> ParentEdge {
self.parent_edge
@ -180,6 +189,7 @@ impl TargetCommand {
ParentEdge::ParentDeathKill => "kill",
ParentEdge::LifetimePipe => "lifetime-pipe",
})
.args(self.terminal.then_some("--target-terminal"))
.args(
self.stdin
.as_ref()

View file

@ -16,6 +16,7 @@ use super::{
use crate::ipc::private_stream_pair;
use crate::user_namespace::UserNamespaceMap;
use std::io::{Read as _, Write as _};
use std::os::unix::process::CommandExt as _;
impl ChildBroker {
/// Spawns the private Host Coordinator bootstrap with exact pipe ownership and pidfd reaping.
@ -44,7 +45,8 @@ impl ChildBroker {
.args(&target.arguments)
.stdin(Stdio::from(lifetime_reader))
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
.stderr(Stdio::inherit())
.process_group(0);
let mut child = command.spawn()?;
let status = child
.stdout
@ -743,6 +745,7 @@ fn run_mapped_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
rustix::process::Uid::ROOT,
rustix::process::Uid::ROOT,
)?;
establish_controlling_terminal(&target)?;
if let Some(stdin) = target.stdin.as_ref() {
rustix::stdio::dup2_stdin(stdin)?;
}
@ -761,12 +764,27 @@ fn run_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
require_lifetime_pipe_and_recheck(expected_parent)?;
}
}
establish_controlling_terminal(&target)?;
if let Some(stdin) = target.stdin.as_ref() {
rustix::stdio::dup2_stdin(stdin)?;
}
Err(exec_target(target))
}
fn establish_controlling_terminal(target: &TargetCommand) -> io::Result<()> {
if !target.terminal {
return Ok(());
}
let stdin = target
.stdin
.as_ref()
.ok_or_else(|| io::Error::other("terminal target stdin is missing"))?;
rustix::process::setsid()?;
rustix::process::ioctl_tiocsctty(stdin)?;
rustix::termios::tcsetpgrp(stdin, rustix::process::getpgrp())?;
Ok(())
}
fn require_lifetime_pipe_and_recheck(
expected_parent: PidfsProcessIdentityV1,
) -> io::Result<RetainedProcess> {

View file

@ -91,7 +91,11 @@ pub fn parse_wrapper_arguments(
Some("lifetime-pipe") => ParentEdge::LifetimePipe,
_ => return Err(io::Error::other("wrapper parent action is invalid")),
};
let next = arguments.next();
let mut next = arguments.next();
let terminal = next.as_deref() == Some(OsStr::new("--target-terminal"));
if terminal {
next = arguments.next();
}
let stdin = if next.as_deref() == Some(OsStr::new("--target-stdin-fd")) {
let descriptor = arguments
.next()
@ -117,6 +121,7 @@ pub fn parse_wrapper_arguments(
parent_edge,
executable: None,
stdin,
terminal,
};
let boot = parse_boot_hex(&boot)?;
Ok((

View file

@ -5,6 +5,7 @@ use std::os::fd::AsFd;
use std::process::{ExitCode, Stdio};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
use lazaret_core::identity::{
CoordinatorId, DomainId, GenerationId, HostPidLocator, PidfsProcessIdentityV1,
@ -13,14 +14,14 @@ use lazaret_core::records::{
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
};
use lazaret_linux::process::{
AcknowledgedChildAcquisition, ChildStandardIo, ProcessOwnershipContext, TargetCommand,
append_parent_identity, child_broker, current_process_identity, dispatch_private_process_role,
verify_direct_child_pidfd,
AcknowledgedChildAcquisition, ChildStandardIo, ProcessOwnershipContext, RetainedProcess,
TargetCommand, append_parent_identity, child_broker, current_process_identity,
dispatch_private_process_role, verify_direct_child_pidfd,
};
use lazaret_linux::user_namespace::UserNamespaceMap;
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::pipe::{PipeFlags, pipe_with};
use rustix::process::{PidfdFlags, pidfd_open};
use rustix::process::{PidfdFlags, Signal, pidfd_open};
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{Winsize, tcgetwinsize, tcsetwinsize};
@ -52,6 +53,8 @@ fn run_all() -> std::io::Result<()> {
mapped_transient_uses_configured_subordinates()?;
transient_captured_child_preserves_streams_and_exit()?;
terminal_child_restores_slave_stdin_and_merges_output()?;
terminal_children_isolate_window_signals()?;
bootstrap_child_uses_a_distinct_process_group()?;
target_stdin_does_not_gain_write_authority()?;
acknowledged_transition_precedes_exact_wrapper_release()?;
failed_acknowledged_transition_refuses_exact_wrapper_release()?;
@ -64,6 +67,33 @@ fn run_all() -> std::io::Result<()> {
concurrent_acquisitions_serialize()
}
fn bootstrap_child_uses_a_distinct_process_group() -> std::io::Result<()> {
let (reader, writer) = pipe_with(PipeFlags::CLOEXEC)?;
let target = TargetCommand::new("/bin/sh")
.argument("-c")
.argument(
"if [ \"$(ps -o pgid= -p $$)\" = \"$(ps -o pgid= -p $PPID)\" ]; then printf E; else printf R; fi; read value || exit 0",
)
.with_lifetime_pipe_parent_edge();
let mut child = child_broker().spawn_bootstrap(&target, File::from(reader))?;
if child.bootstrap_status()? != b'R' {
return Err(std::io::Error::other(
"bootstrap child shared the client process group",
));
}
drop(writer);
while !child.has_exited()? {
std::thread::yield_now();
}
let exit = child.reap()?;
if exit.exit_code != Some(0) || exit.terminating_signal.is_some() {
return Err(std::io::Error::other(format!(
"bootstrap process-group probe failed: {exit:?}"
)));
}
Ok(())
}
fn mapped_transient_uses_configured_subordinates() -> std::io::Result<()> {
let map = UserNamespaceMap::resolve_current()?;
child_broker()
@ -149,7 +179,8 @@ fn terminal_child_restores_slave_stdin_and_merges_output() -> std::io::Result<()
.argument(
"read value; printf 'stdin-fd\\n' >&0; printf 'out:%s\\n' \"$value\"; printf 'err\\n' >&2",
)
.with_target_stdin(slave.try_clone()?)?;
.with_target_stdin(slave.try_clone()?)?
.with_controlling_terminal();
let expected_parent = current_process_identity()?;
let ownership = ProcessOwnershipContext {
domain_id: DomainId::new([41; 32]),
@ -193,6 +224,133 @@ fn terminal_child_restores_slave_stdin_and_merges_output() -> std::io::Result<()
Ok(())
}
fn terminal_children_isolate_window_signals() -> std::io::Result<()> {
let (mut first_master, first) = terminal_child("S1", 51)?;
let (mut second_master, second) = terminal_child("S2", 52)?;
let mut first_output = Vec::new();
let mut second_output = Vec::new();
wait_for_terminal_marker(&mut first_master, &mut first_output, b"S1_READY")?;
wait_for_terminal_marker(&mut second_master, &mut second_output, b"S2_READY")?;
let first_identity = terminal_identity(&first_output, "S1_READY")?;
let second_identity = terminal_identity(&second_output, "S2_READY")?;
if first_identity
.iter()
.any(|value| *value != first_identity[0])
|| second_identity
.iter()
.any(|value| *value != second_identity[0])
|| first_identity[0] == second_identity[0]
{
return Err(std::io::Error::other(
"terminal targets did not receive distinct controlling sessions",
));
}
first.send_signal(Signal::WINCH)?;
wait_for_terminal_marker(&mut first_master, &mut first_output, b"S1_WINCH")?;
second_master.write_all(b"after\n")?;
wait_for_terminal_marker(&mut second_master, &mut second_output, b"S2:after")?;
if second_output
.windows(b"S2_WINCH".len())
.any(|part| part == b"S2_WINCH")
{
return Err(std::io::Error::other(
"window signal crossed terminal process groups",
));
}
while !first.has_exited()? {
std::thread::yield_now();
}
let first_exit = child_broker().reap_status(first)?;
second.send_signal(Signal::KILL)?;
let second_exit = child_broker().reap_status(second)?;
if first_exit.exit_code != Some(0)
|| first_exit.terminating_signal.is_some()
|| second_exit.terminating_signal.is_none()
{
return Err(std::io::Error::other(
"terminal signal isolation produced unexpected exits",
));
}
Ok(())
}
fn terminal_child(marker: &str, seed: u8) -> std::io::Result<(File, RetainedProcess)> {
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)?;
grantpt(&master)?;
unlockpt(&master)?;
let slave = File::from(ioctl_tiocgptpeer(
&master,
OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC,
)?);
let target = TargetCommand::new("/usr/bin/python3")
.argument("-c")
.argument(format!(
"import os, signal, sys\nmarker = {marker:?}\ndef winch(*_):\n print(marker + '_WINCH', flush=True)\n sys.exit(0)\nsignal.signal(signal.SIGWINCH, winch)\nprint(f'{{marker}}_READY pid={{os.getpid()}} sid={{os.getsid(0)}} pgrp={{os.getpgrp()}} tty={{os.tcgetpgrp(0)}}', flush=True)\nfor line in sys.stdin:\n print(marker + ':' + line.strip(), flush=True)"
))
.with_target_stdin(slave.try_clone()?)?
.with_controlling_terminal();
let process = child_broker().acquire_with_stdio(
current_process_identity()?,
ProcessOwnershipContext {
domain_id: DomainId::new([seed; 32]),
generation_id: GenerationId::new(u64::from(seed)),
coordinator_id: CoordinatorId::new([seed.wrapping_add(1); 32]),
slot: HostProcessOwnershipSlot::SessionTerminalExecClient(
lazaret_core::identity::SessionId::new([seed.wrapping_add(2); 32]),
),
},
&target,
ChildStandardIo::terminal(slave)?,
|_| Ok(()),
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
)?;
let master = File::from(master);
let flags = fcntl_getfl(&master)?;
fcntl_setfl(&master, flags | OFlags::NONBLOCK)?;
Ok((master, process))
}
fn terminal_identity(output: &[u8], marker: &str) -> std::io::Result<[u32; 4]> {
let text = String::from_utf8_lossy(output);
let line = text
.lines()
.find(|line| line.starts_with(marker))
.ok_or_else(|| std::io::Error::other("terminal identity marker is missing"))?;
let values = line
.split_whitespace()
.skip(1)
.map(|field| {
field
.split_once('=')
.and_then(|(_, value)| value.parse::<u32>().ok())
.ok_or_else(|| std::io::Error::other("terminal identity field is invalid"))
})
.collect::<std::io::Result<Vec<_>>>()?;
values
.try_into()
.map_err(|_| std::io::Error::other("terminal identity field count is invalid"))
}
fn wait_for_terminal_marker(
master: &mut File,
output: &mut Vec<u8>,
marker: &[u8],
) -> std::io::Result<()> {
let deadline = Instant::now() + Duration::from_secs(10);
while !output.windows(marker.len()).any(|part| part == marker) {
read_available(master, output)?;
if Instant::now() >= deadline {
return Err(std::io::Error::other(format!(
"terminal marker {:?} deadline expired with {:?}",
String::from_utf8_lossy(marker),
String::from_utf8_lossy(output)
)));
}
std::thread::yield_now();
}
Ok(())
}
fn transient_captured_child_preserves_streams_and_exit() -> std::io::Result<()> {
let target = TargetCommand::new("/bin/sh")
.argument("-c")