Compose foreground sandbox process lifetimes

This commit is contained in:
ms2k 2026-07-15 19:09:24 +09:00
commit fd78804fc7
3 changed files with 2313 additions and 7 deletions

View file

@ -14,6 +14,18 @@ pub fn private_seqpacket_pair() -> std::io::Result<(OwnedFd, OwnedFd)> {
.map_err(Into::into)
}
/// # Errors
/// Returns an error when the private stream pair cannot be created.
pub fn private_stream_pair() -> std::io::Result<(OwnedFd, OwnedFd)> {
socketpair(
AddressFamily::UNIX,
SocketType::STREAM,
SocketFlags::CLOEXEC,
None,
)
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use std::os::fd::AsFd;

View file

@ -1,7 +1,7 @@
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::{AsFd, OwnedFd};
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::process::Stdio;
use std::sync::{Mutex, OnceLock};
@ -29,6 +29,13 @@ static CHILD_BROKER: OnceLock<ChildBroker> = OnceLock::new();
pub struct TargetCommand {
program: OsString,
arguments: Vec<OsString>,
parent_action: ParentAction,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ParentAction {
Kill,
LifetimePipe,
}
impl TargetCommand {
@ -37,6 +44,7 @@ impl TargetCommand {
Self {
program: program.into(),
arguments: Vec::new(),
parent_action: ParentAction::Kill,
}
}
@ -46,9 +54,46 @@ impl TargetCommand {
self
}
#[must_use]
pub fn with_lifetime_pipe_parent_edge(mut self) -> Self {
self.parent_action = ParentAction::LifetimePipe;
self
}
#[allow(clippy::disallowed_types)]
fn append_to(&self, command: &mut std::process::Command) {
command.arg("--").arg(&self.program).args(&self.arguments);
command
.arg("--parent-action")
.arg(match self.parent_action {
ParentAction::Kill => "kill",
ParentAction::LifetimePipe => "lifetime-pipe",
})
.arg("--")
.arg(&self.program)
.args(&self.arguments);
}
}
pub struct ChildStandardIo {
stdout: Option<Stdio>,
stderr: Option<Stdio>,
}
impl ChildStandardIo {
#[must_use]
pub const fn inherit() -> Self {
Self {
stdout: None,
stderr: None,
}
}
#[must_use]
pub fn redirected(stdout: impl Into<Stdio>, stderr: impl Into<Stdio>) -> Self {
Self {
stdout: Some(stdout.into()),
stderr: Some(stderr.into()),
}
}
}
@ -90,6 +135,12 @@ impl RetainedProcess {
}
}
impl AsFd for RetainedProcess {
fn as_fd(&self) -> BorrowedFd<'_> {
self.pidfd.as_fd()
}
}
#[derive(Debug)]
pub struct ChildBroker {
exclusion: Mutex<()>,
@ -117,6 +168,32 @@ impl ChildBroker {
stage: Stage,
acknowledge: Acknowledge,
) -> io::Result<RetainedProcess>
where
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
{
self.acquire_with_stdio(
expected_parent,
ownership,
target,
ChildStandardIo::inherit(),
stage,
acknowledge,
)
}
/// # Errors
/// Refuses and reaps the blocked wrapper on every failed acquisition or callback.
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
pub fn acquire_with_stdio<Stage, Acknowledge>(
&self,
expected_parent: PidfsProcessIdentityV1,
ownership: ProcessOwnershipContext,
target: &TargetCommand,
mut standard_io: ChildStandardIo,
stage: Stage,
acknowledge: Acknowledge,
) -> io::Result<RetainedProcess>
where
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
@ -132,6 +209,12 @@ impl ChildBroker {
append_parent_identity(&mut command, expected_parent);
target.append_to(&mut command);
command.stdin(wait.into_stdio());
if let Some(stdout) = standard_io.stdout.take() {
command.stdout(stdout);
}
if let Some(stderr) = standard_io.stderr.take() {
command.stderr(stderr);
}
let mut child = command.spawn()?;
let host_pid = child.id();
let acquired = acquire_spawned_child(host_pid);
@ -217,10 +300,42 @@ fn run_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
let (expected_parent, target) = parse_wrapper_arguments(arguments)?;
BarrierWait::from_stdin()?.wait()?;
rustix::io::fcntl_setfd(std::io::stdin(), rustix::io::FdFlags::CLOEXEC)?;
arm_parent_death_and_recheck(Signal::KILL, expected_parent)?;
match target.parent_action {
ParentAction::Kill => {
arm_parent_death_and_recheck(Signal::KILL, expected_parent)?;
}
ParentAction::LifetimePipe => {
require_lifetime_pipe_and_recheck(expected_parent)?;
}
}
Err(exec_target(target))
}
fn require_lifetime_pipe_and_recheck(
expected_parent: PidfsProcessIdentityV1,
) -> io::Result<RetainedProcess> {
let stderr = std::io::stderr();
let mut descriptors = [PollFd::new(&stderr, PollFlags::IN)];
poll(&mut descriptors, Some(&Timespec::default()))?;
if descriptors[0]
.revents()
.intersects(PollFlags::IN | PollFlags::HUP | PollFlags::ERR)
{
return Err(io::Error::other("parent lifetime pipe is not held"));
}
let parent = recover_process(expected_parent)?;
let observed_parent = rustix::process::getppid()
.map(Pid::as_raw_nonzero)
.map(i32::from)
.and_then(|pid| u32::try_from(pid).ok());
if observed_parent != Some(expected_parent.host_pid().get()) {
return Err(io::Error::other(
"parent changed while arming lifetime pipe",
));
}
Ok(parent)
}
#[allow(clippy::disallowed_methods)]
fn acquire_spawned_child(host_pid: u32) -> io::Result<RetainedProcess> {
let raw_pid = i32::try_from(host_pid)
@ -658,6 +773,11 @@ pub fn parse_wrapper_arguments(
let inode = number_argument(&mut arguments, "--parent-inode")?;
let pid = number_argument(&mut arguments, "--parent-pid")?;
let start = number_argument(&mut arguments, "--parent-start")?;
let parent_action = match named_argument(&mut arguments, "--parent-action")?.to_str() {
Some("kill") => ParentAction::Kill,
Some("lifetime-pipe") => ParentAction::LifetimePipe,
_ => return Err(io::Error::other("wrapper parent action is invalid")),
};
if arguments.next().as_deref() != Some(OsStr::new("--")) {
return Err(io::Error::other("wrapper target separator is missing"));
}
@ -667,6 +787,7 @@ pub fn parse_wrapper_arguments(
let target = TargetCommand {
program,
arguments: arguments.collect(),
parent_action,
};
let boot = parse_boot_hex(&boot)?;
Ok((

View file

@ -13,9 +13,10 @@ use lazaret_core::records::{
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
};
use lazaret_linux::process::{
ProcessOwnershipContext, TargetCommand, append_parent_identity, child_broker,
ChildStandardIo, ProcessOwnershipContext, TargetCommand, append_parent_identity, child_broker,
current_process_identity, dispatch_private_process_role, verify_direct_child_pidfd,
};
use rustix::pipe::{PipeFlags, pipe_with};
use rustix::process::{PidfdFlags, pidfd_open};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
@ -43,6 +44,7 @@ fn exit(result: std::io::Result<()>) -> ExitCode {
fn run_all() -> std::io::Result<()> {
successful_acquisition()?;
lifetime_pipe_acquisition()?;
failed_stages_and_acknowledgments()?;
production_wrapper_refuses_former_harness_path()?;
malformed_barriers_refuse()?;
@ -51,6 +53,32 @@ fn run_all() -> std::io::Result<()> {
concurrent_acquisitions_serialize()
}
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
fn lifetime_pipe_acquisition() -> std::io::Result<()> {
let marker = marker("lifetime-pipe");
let parent = current_process_identity()?;
let target = TargetCommand::new("/usr/bin/touch")
.argument(&marker)
.with_lifetime_pipe_parent_edge();
let (lifetime_reader, lifetime_writer) = pipe_with(PipeFlags::CLOEXEC)?;
let process = child_broker().acquire_with_stdio(
parent,
context(),
&target,
ChildStandardIo::redirected(Stdio::null(), Stdio::from(lifetime_reader)),
|_| Ok(()),
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
)?;
child_broker().reap(process)?;
drop(lifetime_writer);
if !marker.is_file() {
return Err(std::io::Error::other(
"lifetime-pipe acquisition did not execute its target",
));
}
std::fs::remove_file(marker)
}
fn marker(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"lazaret-acquisition-{name}-{}-{}",
@ -243,7 +271,9 @@ fn malformed_barriers_refuse() -> std::io::Result<()> {
.arg("--lazaret-private-process-role")
.arg("launch-wrapper");
append_parent_identity(&mut command, parent);
command.arg("--").arg("/usr/bin/touch").arg(&marker);
command
.args(["--parent-action", "kill", "--", "/usr/bin/touch"])
.arg(&marker);
command.stdin(Stdio::from(temporary_file(&bytes)?));
if command.status()?.success() || marker.exists() {
return Err(std::io::Error::other("malformed barrier released target"));