Enforce causal child acquisition boundaries
This commit is contained in:
parent
1468f6b841
commit
6f722ce079
2 changed files with 603 additions and 146 deletions
|
|
@ -1,10 +1,7 @@
|
|||
use std::ffi::OsString;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use lazaret_linux::process::{
|
||||
BarrierWait, arm_parent_death_and_recheck, exec_target, parse_wrapper_arguments,
|
||||
};
|
||||
use rustix::process::Signal;
|
||||
use lazaret_linux::process::{dispatch_private_process_role, exec_target};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
|
|
@ -18,6 +15,9 @@ fn main() -> ExitCode {
|
|||
|
||||
fn run() -> std::io::Result<()> {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<OsString>>();
|
||||
if let Some(result) = dispatch_private_process_role(&arguments) {
|
||||
return result;
|
||||
}
|
||||
if arguments == [OsString::from("--trace-self-test")] {
|
||||
let (release, wait) = lazaret_linux::process::launch_barrier()?;
|
||||
release.release()?;
|
||||
|
|
@ -26,9 +26,7 @@ fn run() -> std::io::Result<()> {
|
|||
"/bin/true",
|
||||
)));
|
||||
}
|
||||
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)?;
|
||||
Err(exec_target(target))
|
||||
Err(std::io::Error::other(
|
||||
"no public wrapper command was requested",
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ use std::ffi::{OsStr, OsString};
|
|||
use std::fs::File;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use lazaret_core::identity::{
|
||||
BootId, HostPidLocator, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
|
||||
PidfsProcessIdentityV1, ProcessStartTimeTicks,
|
||||
BootId, CoordinatorId, DomainId, GenerationId, HostPidLocator, PidfsDeviceId, PidfsInodeId,
|
||||
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, ProcessStartTimeTicks,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
|
||||
};
|
||||
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
||||
use rustix::fs::{fstat, fstatfs};
|
||||
|
|
@ -18,6 +20,9 @@ use rustix::process::{Pid, PidfdFlags, Signal, WaitId, WaitIdOptions};
|
|||
|
||||
pub const PID_FS_MAGIC: i64 = 0x5049_4446;
|
||||
const BARRIER_RELEASE: &[u8; 32] = b"LAZARET-CAUSAL-BARRIER-RELEASE!!";
|
||||
const PRIVATE_ROLE_ARGUMENT: &str = "--lazaret-private-process-role";
|
||||
const WRAPPER_ROLE: &str = "launch-wrapper";
|
||||
const SIGCHLD_PROBE_ROLE: &str = "sigchld-probe";
|
||||
static CHILD_BROKER: OnceLock<ChildBroker> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
|
@ -47,6 +52,14 @@ impl TargetCommand {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ProcessOwnershipContext {
|
||||
pub domain_id: DomainId,
|
||||
pub generation_id: GenerationId,
|
||||
pub coordinator_id: CoordinatorId,
|
||||
pub slot: HostProcessOwnershipSlot,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RetainedProcess {
|
||||
pidfd: OwnedFd,
|
||||
|
|
@ -96,25 +109,26 @@ impl ChildBroker {
|
|||
/// # Errors
|
||||
/// Refuses and reaps the blocked wrapper on every failed acquisition or callback.
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
pub fn acquire<S, Stage, Acknowledge>(
|
||||
pub fn acquire<Stage, Acknowledge>(
|
||||
&self,
|
||||
wrapper: &Path,
|
||||
expected_parent: PidfsProcessIdentityV1,
|
||||
ownership: ProcessOwnershipContext,
|
||||
target: &TargetCommand,
|
||||
stage: Stage,
|
||||
acknowledge: Acknowledge,
|
||||
) -> io::Result<RetainedProcess>
|
||||
where
|
||||
Stage: FnOnce(PidfsProcessIdentityV1) -> io::Result<S>,
|
||||
Acknowledge: FnOnce(&S, PidfsProcessIdentityV1) -> io::Result<()>,
|
||||
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
|
||||
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
|
||||
{
|
||||
let _guard = self
|
||||
.exclusion
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||
require_default_sigchld()?;
|
||||
require_reapable_sigchld()?;
|
||||
let (release, wait) = launch_barrier()?;
|
||||
let mut command = std::process::Command::new(wrapper);
|
||||
let mut command = private_role_command(WRAPPER_ROLE);
|
||||
append_parent_identity(&mut command, expected_parent);
|
||||
target.append_to(&mut command);
|
||||
command.stdin(wait.into_stdio());
|
||||
|
|
@ -125,12 +139,28 @@ impl ChildBroker {
|
|||
Ok(process) => process,
|
||||
Err(error) => return fail_before_pidfd(release, &mut child, error),
|
||||
};
|
||||
let staged = match stage(process.identity) {
|
||||
Ok(staged) => staged,
|
||||
let staged = StagedProcessOwnership::v1(
|
||||
ownership.domain_id,
|
||||
ownership.generation_id,
|
||||
ownership.coordinator_id,
|
||||
ownership.slot,
|
||||
process.identity,
|
||||
);
|
||||
if let Err(error) = stage(staged) {
|
||||
return fail_after_pidfd(release, process, error);
|
||||
}
|
||||
let acknowledgment = match acknowledge(staged) {
|
||||
Ok(acknowledgment) => acknowledgment,
|
||||
Err(error) => return fail_after_pidfd(release, process, error),
|
||||
};
|
||||
if let Err(error) = acknowledge(&staged, process.identity) {
|
||||
return fail_after_pidfd(release, process, error);
|
||||
if !acknowledgment.acknowledges(&staged) {
|
||||
return fail_after_pidfd(
|
||||
release,
|
||||
process,
|
||||
io::Error::other(
|
||||
"process ownership acknowledgment does not match staged ownership",
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Err(error) = release.release() {
|
||||
return fail_after_pidfd_closed(process, error);
|
||||
|
|
@ -153,6 +183,44 @@ impl ChildBroker {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_types)]
|
||||
fn private_role_command(role: &str) -> std::process::Command {
|
||||
let mut command = std::process::Command::new("/proc/self/exe");
|
||||
command.arg(PRIVATE_ROLE_ARGUMENT).arg(role);
|
||||
command
|
||||
}
|
||||
|
||||
/// Runs a private self-exec role before normal argument handling.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns the role's fail-closed error when a private invocation is malformed.
|
||||
pub fn dispatch_private_process_role(arguments: &[OsString]) -> Option<io::Result<()>> {
|
||||
if arguments
|
||||
.first()
|
||||
.is_none_or(|argument| argument != PRIVATE_ROLE_ARGUMENT)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let role = arguments.get(1).and_then(|argument| argument.to_str());
|
||||
match role {
|
||||
Some(SIGCHLD_PROBE_ROLE) if arguments.len() == 2 => {
|
||||
Some(BarrierWait::from_stdin().and_then(BarrierWait::wait))
|
||||
}
|
||||
Some(WRAPPER_ROLE) => Some(run_wrapper_role(arguments[2..].to_vec())),
|
||||
_ => Some(Err(io::Error::other(
|
||||
"unknown or malformed private process role",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
Err(exec_target(target))
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn acquire_spawned_child(host_pid: u32) -> io::Result<RetainedProcess> {
|
||||
let raw_pid = i32::try_from(host_pid)
|
||||
|
|
@ -406,6 +474,101 @@ fn require_default_sigchld() -> io::Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn require_reapable_sigchld() -> io::Result<()> {
|
||||
let (release, wait) = launch_barrier()?;
|
||||
let mut command = private_role_command(SIGCHLD_PROBE_ROLE);
|
||||
command.stdin(wait.into_stdio());
|
||||
let mut child = command.spawn()?;
|
||||
let host_pid = child.id();
|
||||
let raw_pid = i32::try_from(host_pid)
|
||||
.map_err(|_| io::Error::other("SIGCHLD probe returned out-of-range child PID"))?;
|
||||
let pid = Pid::from_raw(raw_pid)
|
||||
.ok_or_else(|| io::Error::other("SIGCHLD probe returned invalid child PID"))?;
|
||||
let pidfd = match rustix::process::pidfd_open(pid, PidfdFlags::empty()) {
|
||||
Ok(pidfd) => pidfd,
|
||||
Err(error) => return fail_sigchld_probe(release, &mut child, error.into()),
|
||||
};
|
||||
if let Err(error) = verify_direct_child_pidfd(pidfd.as_fd()) {
|
||||
return fail_sigchld_probe(release, &mut child, error);
|
||||
}
|
||||
if let Err(error) = release.release() {
|
||||
return cleanup_sigchld_probe(pidfd.as_fd(), child, error);
|
||||
}
|
||||
if let Err(error) = rustix::process::waitid(WaitId::PidFd(pidfd.as_fd()), WaitIdOptions::EXITED)
|
||||
{
|
||||
return cleanup_sigchld_probe(
|
||||
pidfd.as_fd(),
|
||||
child,
|
||||
io::Error::new(
|
||||
error.kind(),
|
||||
format!("SIGCHLD semantic probe was not reapable: {error}"),
|
||||
),
|
||||
);
|
||||
}
|
||||
drop(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn cleanup_sigchld_probe(
|
||||
pidfd: std::os::fd::BorrowedFd<'_>,
|
||||
child: std::process::Child,
|
||||
error: io::Error,
|
||||
) -> io::Result<()> {
|
||||
let result = cleanup_sigchld_probe_with(
|
||||
error,
|
||||
|| rustix::process::pidfd_send_signal(pidfd, Signal::KILL).map_err(Into::into),
|
||||
|| {
|
||||
rustix::process::waitid(WaitId::PidFd(pidfd), WaitIdOptions::EXITED)
|
||||
.map(|_| ())
|
||||
.map_err(Into::into)
|
||||
},
|
||||
);
|
||||
drop(child);
|
||||
result
|
||||
}
|
||||
|
||||
fn cleanup_sigchld_probe_with<Terminate, Wait>(
|
||||
error: io::Error,
|
||||
terminate: Terminate,
|
||||
mut wait: Wait,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
Terminate: FnOnce() -> io::Result<()>,
|
||||
Wait: FnMut() -> io::Result<()>,
|
||||
{
|
||||
let _ = terminate();
|
||||
loop {
|
||||
match wait() {
|
||||
Ok(()) => return Err(error),
|
||||
Err(wait_error) if wait_error.kind() == io::ErrorKind::Interrupted => {}
|
||||
Err(wait_error)
|
||||
if wait_error.raw_os_error() == Some(rustix::io::Errno::CHILD.raw_os_error()) =>
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
Err(wait_error) => {
|
||||
return Err(io::Error::new(
|
||||
error.kind(),
|
||||
format!("{error}; exact SIGCHLD probe cleanup failed: {wait_error}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn fail_sigchld_probe(
|
||||
release: BarrierRelease,
|
||||
child: &mut std::process::Child,
|
||||
error: io::Error,
|
||||
) -> io::Result<()> {
|
||||
drop(release);
|
||||
let _ = child.wait();
|
||||
Err(error)
|
||||
}
|
||||
|
||||
fn signal_mask(status: &str, name: &str) -> io::Result<u128> {
|
||||
status
|
||||
.lines()
|
||||
|
|
@ -695,4 +858,44 @@ mod tests {
|
|||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigchld_probe_epipe_terminates_and_reaps_exact_child() {
|
||||
let events = Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let terminate_events = Rc::clone(&events);
|
||||
let wait_events = Rc::clone(&events);
|
||||
let result = cleanup_sigchld_probe_with(
|
||||
io::Error::new(io::ErrorKind::BrokenPipe, "probe died before release"),
|
||||
move || {
|
||||
terminate_events.borrow_mut().push("terminate-pidfd");
|
||||
Err(io::Error::from_raw_os_error(3))
|
||||
},
|
||||
move || {
|
||||
wait_events.borrow_mut().push("wait-pidfd");
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
assert_eq!(&*events.borrow(), &["terminate-pidfd", "wait-pidfd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigchld_probe_failed_wait_retries_cleanup_until_not_waitable() {
|
||||
let waits = Cell::new(0_u8);
|
||||
let result = cleanup_sigchld_probe_with(
|
||||
io::Error::other("injected semantic wait failure"),
|
||||
|| Ok(()),
|
||||
|| {
|
||||
waits.set(waits.get() + 1);
|
||||
match waits.get() {
|
||||
1 => Err(io::Error::from(io::ErrorKind::Interrupted)),
|
||||
_ => Err(io::Error::from_raw_os_error(
|
||||
rustix::io::Errno::CHILD.raw_os_error(),
|
||||
)),
|
||||
}
|
||||
},
|
||||
);
|
||||
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Other);
|
||||
assert_eq!(waits.get(), 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue