lazaret/crates/lazaret-linux/src/process.rs

1114 lines
36 KiB
Rust
Raw Normal View History

use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::process::Stdio;
use std::sync::{Mutex, OnceLock};
use lazaret_core::identity::{
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};
use rustix::io::{FdFlags, fcntl_setfd};
use rustix::pipe::{PipeFlags, pipe_with};
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)]
pub struct TargetCommand {
program: OsString,
arguments: Vec<OsString>,
parent_edge: ParentEdge,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ParentEdge {
ParentDeathKill,
LifetimePipe,
}
impl TargetCommand {
#[must_use]
pub fn new(program: impl Into<OsString>) -> Self {
Self {
program: program.into(),
arguments: Vec::new(),
parent_edge: ParentEdge::ParentDeathKill,
}
}
#[must_use]
pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
self.arguments.push(argument.into());
self
}
#[must_use]
pub fn with_lifetime_pipe_parent_edge(mut self) -> Self {
self.parent_edge = ParentEdge::LifetimePipe;
self
}
#[must_use]
pub fn with_parent_death_kill_edge(mut self) -> Self {
self.parent_edge = ParentEdge::ParentDeathKill;
self
}
#[must_use]
pub const fn parent_edge(&self) -> ParentEdge {
self.parent_edge
}
#[allow(clippy::disallowed_types)]
fn append_to(&self, command: &mut std::process::Command) {
command
.arg("--parent-action")
.arg(match self.parent_edge {
ParentEdge::ParentDeathKill => "kill",
ParentEdge::LifetimePipe => "lifetime-pipe",
})
.arg("--")
.arg(&self.program)
.args(&self.arguments);
}
}
pub struct ChildStandardIo {
stdout: Option<Stdio>,
stderr: Option<Stdio>,
policy: ChildStandardIoPolicy,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ChildStandardIoPolicy {
Inherit,
PrivateControlWithLifetimePipe,
Silenced,
}
impl ChildStandardIo {
#[must_use]
pub const fn inherit() -> Self {
Self {
stdout: None,
stderr: None,
policy: ChildStandardIoPolicy::Inherit,
}
}
#[must_use]
pub fn private_control_with_lifetime_pipe(
control: impl Into<Stdio>,
lifetime_pipe: impl Into<Stdio>,
) -> Self {
Self {
stdout: Some(control.into()),
stderr: Some(lifetime_pipe.into()),
policy: ChildStandardIoPolicy::PrivateControlWithLifetimePipe,
}
}
#[must_use]
pub fn silenced() -> Self {
Self {
stdout: Some(Stdio::null()),
stderr: Some(Stdio::null()),
policy: ChildStandardIoPolicy::Silenced,
}
}
#[must_use]
pub const fn policy(&self) -> ChildStandardIoPolicy {
self.policy
}
}
#[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,
identity: PidfsProcessIdentityV1,
}
impl RetainedProcess {
#[must_use]
pub const fn identity(&self) -> PidfsProcessIdentityV1 {
self.identity
}
/// # Errors
/// Returns an error when the exact retained-pidfd action fails.
#[allow(clippy::disallowed_methods)]
pub fn send_signal(&self, signal: Signal) -> io::Result<()> {
rustix::process::pidfd_send_signal(self.pidfd.as_fd(), signal).map_err(Into::into)
}
/// # Errors
/// Returns an error when polling the retained pidfd fails.
pub fn has_exited(&self) -> io::Result<bool> {
let mut descriptors = [PollFd::new(&self.pidfd, PollFlags::IN)];
poll(&mut descriptors, Some(&Timespec::default()))?;
Ok(descriptors[0]
.revents()
.intersects(PollFlags::IN | PollFlags::HUP))
}
}
impl AsFd for RetainedProcess {
fn as_fd(&self) -> BorrowedFd<'_> {
self.pidfd.as_fd()
}
}
#[derive(Debug)]
pub struct ChildBroker {
exclusion: Mutex<()>,
}
#[must_use]
pub fn child_broker() -> &'static ChildBroker {
CHILD_BROKER.get_or_init(|| ChildBroker {
exclusion: Mutex::new(()),
})
}
fn require_parent_edge_standard_io(
parent_edge: ParentEdge,
standard_io: ChildStandardIoPolicy,
) -> io::Result<()> {
match (parent_edge, standard_io) {
(ParentEdge::LifetimePipe, ChildStandardIoPolicy::PrivateControlWithLifetimePipe)
| (
ParentEdge::ParentDeathKill,
ChildStandardIoPolicy::Inherit | ChildStandardIoPolicy::Silenced,
) => Ok(()),
(ParentEdge::LifetimePipe, _) => Err(io::Error::other(
"lifetime-pipe parent edge requires private control stdout and lifetime-pipe stderr",
)),
(ParentEdge::ParentDeathKill, ChildStandardIoPolicy::PrivateControlWithLifetimePipe) => {
Err(io::Error::other(
"parent-death edge cannot substitute a lifetime-pipe stdio policy",
))
}
}
}
impl ChildBroker {
/// Spawns the repository wrapper, stages exact ownership, accepts its acknowledgment,
/// and only then releases target execution.
///
/// # Errors
/// Refuses and reaps the blocked wrapper on every failed acquisition or callback.
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
pub fn acquire<Stage, Acknowledge>(
&self,
expected_parent: PidfsProcessIdentityV1,
ownership: ProcessOwnershipContext,
target: &TargetCommand,
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>,
{
let _guard = self
.exclusion
.lock()
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
require_parent_edge_standard_io(target.parent_edge(), standard_io.policy())?;
require_default_sigchld()?;
require_reapable_sigchld()?;
let (release, wait) = launch_barrier()?;
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());
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);
let process = match acquired {
Ok(process) => process,
Err(error) => return fail_before_pidfd(release, &mut child, error),
};
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 !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);
}
drop(child);
Ok(process)
}
/// # Errors
/// Returns an error when serialization or exact pidfd reaping fails.
#[allow(clippy::disallowed_methods)]
pub fn reap(&self, process: RetainedProcess) -> io::Result<()> {
let _guard = self
.exclusion
.lock()
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
drop(process);
Ok(())
}
}
#[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)?;
match target.parent_edge {
ParentEdge::ParentDeathKill => {
arm_parent_death_and_recheck(Signal::KILL, expected_parent)?;
}
ParentEdge::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)
.map_err(|_| io::Error::other("spawn returned out-of-range child PID"))?;
let pid = Pid::from_raw(raw_pid)
.ok_or_else(|| io::Error::other("spawn returned invalid child PID"))?;
require_host_pid_namespace(host_pid)?;
let pidfd = rustix::process::pidfd_open(pid, PidfdFlags::empty())?;
verify_direct_child_pidfd(pidfd.as_fd())?;
let authority = authority_from_pidfd(pidfd.as_fd(), read_boot_id()?)?;
let start_time = read_start_time(host_pid)?;
Ok(RetainedProcess {
pidfd,
identity: PidfsProcessIdentityV1::new(
authority,
HostPidLocator::new(host_pid),
ProcessStartTimeTicks::new(start_time),
),
})
}
/// # Errors
/// Refuses descriptors that do not name a waitable direct child of this broker process.
#[allow(clippy::disallowed_methods)]
pub fn verify_direct_child_pidfd(pidfd: impl AsFd) -> io::Result<()> {
rustix::process::waitid(
WaitId::PidFd(pidfd.as_fd()),
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
)?;
Ok(())
}
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
fn fail_before_pidfd(
release: BarrierRelease,
child: &mut std::process::Child,
error: io::Error,
) -> io::Result<RetainedProcess> {
drop(release);
child.wait()?;
Err(error)
}
fn fail_after_pidfd(
release: BarrierRelease,
process: RetainedProcess,
error: io::Error,
) -> io::Result<RetainedProcess> {
drop(release);
fail_after_pidfd_closed(process, error)
}
#[allow(clippy::disallowed_methods)]
fn fail_after_pidfd_closed(
process: RetainedProcess,
error: io::Error,
) -> io::Result<RetainedProcess> {
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
drop(process);
Err(error)
}
/// # Errors
/// Refuses before PID lookup on boot mismatch and before action on every later mismatch.
#[allow(clippy::disallowed_methods)]
pub fn recover_process(identity: PidfsProcessIdentityV1) -> io::Result<RetainedProcess> {
recover_process_with(identity, read_boot_id, |host_pid| {
let raw_pid = i32::try_from(host_pid)
.map_err(|_| io::Error::other("stored PID locator is out of range"))?;
let pid = Pid::from_raw(raw_pid)
.ok_or_else(|| io::Error::other("stored PID locator is invalid"))?;
require_host_pid_namespace(host_pid)?;
rustix::process::pidfd_open(pid, PidfdFlags::empty()).map_err(Into::into)
})
}
fn recover_process_with<Boot, Open>(
identity: PidfsProcessIdentityV1,
boot: Boot,
open: Open,
) -> io::Result<RetainedProcess>
where
Boot: FnOnce() -> io::Result<BootId>,
Open: FnOnce(u32) -> io::Result<OwnedFd>,
{
let current_boot = boot()?;
if current_boot != identity.authority().boot_id {
return Err(io::Error::other(
"stored process authority belongs to another boot",
));
}
let host_pid = identity.host_pid().get();
let pidfd = open(host_pid)?;
let observed = authority_from_pidfd(pidfd.as_fd(), current_boot)?;
if observed != identity.authority() {
return Err(io::Error::other("pidfs process authority mismatch"));
}
if read_start_time(host_pid)? != identity.start_time_ticks().get() {
return Err(io::Error::other("process start time mismatch"));
}
Ok(RetainedProcess { pidfd, identity })
}
/// # Errors
/// Refuses if arming fails or the exact parent changes during the recheck.
pub fn arm_parent_death_and_recheck(
signal: Signal,
expected_parent: PidfsProcessIdentityV1,
) -> io::Result<RetainedProcess> {
rustix::process::set_parent_process_death_signal(Some(signal))?;
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 parent-death action",
));
}
Ok(parent)
}
/// # Errors
/// Returns an error if the current process cannot form a same-pidfd identity.
#[allow(clippy::disallowed_methods)]
pub fn current_process_identity() -> io::Result<PidfsProcessIdentityV1> {
let pid = rustix::process::getpid();
let host_pid = u32::try_from(i32::from(pid.as_raw_nonzero()))
.map_err(|_| io::Error::other("current PID is out of range"))?;
require_host_pid_namespace(host_pid)?;
let pidfd = rustix::process::pidfd_open(pid, PidfdFlags::empty())?;
let authority = authority_from_pidfd(pidfd.as_fd(), read_boot_id()?)?;
Ok(PidfsProcessIdentityV1::new(
authority,
HostPidLocator::new(host_pid),
ProcessStartTimeTicks::new(read_start_time(host_pid)?),
))
}
fn authority_from_pidfd(pidfd: impl AsFd, boot_id: BootId) -> io::Result<PidfsProcessAuthorityV1> {
authority_from_pidfd_with(
pidfd,
boot_id,
|fd| Ok(fstatfs(fd)?.f_type as i64),
|fd| {
let stat = fstat(fd)?;
Ok((stat.st_dev, stat.st_ino))
},
)
}
fn authority_from_pidfd_with<Fs, Stat>(
pidfd: impl AsFd,
boot_id: BootId,
filesystem: Fs,
stat: Stat,
) -> io::Result<PidfsProcessAuthorityV1>
where
Fs: FnOnce(std::os::fd::BorrowedFd<'_>) -> io::Result<i64>,
Stat: FnOnce(std::os::fd::BorrowedFd<'_>) -> io::Result<(u64, u64)>,
{
require_pidfs_magic(filesystem(pidfd.as_fd())?)?;
let (device, inode) = stat(pidfd.as_fd())?;
Ok(PidfsProcessAuthorityV1 {
boot_id,
pidfs_st_dev: PidfsDeviceId::new(device),
pidfs_st_ino: PidfsInodeId::new(inode),
})
}
/// # Errors
/// Refuses every filesystem magic other than `PID_FS_MAGIC`.
pub fn require_pidfs_magic(magic: i64) -> io::Result<()> {
if magic == PID_FS_MAGIC {
Ok(())
} else {
Err(io::Error::other("descriptor is not a pidfs process handle"))
}
}
/// # Errors
/// Refuses unreadable or non-canonical boot identifiers.
pub fn read_boot_id() -> io::Result<BootId> {
parse_boot_id(&std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?)
}
/// # Errors
/// Refuses every non-canonical UUID spelling.
pub fn parse_boot_id(value: &str) -> io::Result<BootId> {
let value = value.strip_suffix('\n').unwrap_or(value);
if value.len() != 36
|| value.as_bytes()[8] != b'-'
|| value.as_bytes()[13] != b'-'
|| value.as_bytes()[18] != b'-'
|| value.as_bytes()[23] != b'-'
|| value
.bytes()
.enumerate()
.any(|(index, byte)| !matches!(index, 8 | 13 | 18 | 23) && !byte.is_ascii_hexdigit())
{
return Err(io::Error::other("boot ID is not a canonical UUID"));
}
let compact = value.replace('-', "");
let mut bytes = [0_u8; 16];
for (index, byte) in bytes.iter_mut().enumerate() {
*byte = u8::from_str_radix(&compact[index * 2..index * 2 + 2], 16)
.map_err(|_| io::Error::other("boot ID is not a canonical UUID"))?;
}
Ok(BootId::new(bytes))
}
/// # Errors
/// Refuses unreadable or malformed process statistics.
pub fn read_start_time(host_pid: u32) -> io::Result<u64> {
let stat = std::fs::read_to_string(format!("/proc/{host_pid}/stat"))?;
let command_end = stat
.rfind(") ")
.ok_or_else(|| io::Error::other("process stat has no command terminator"))?;
stat[command_end + 2..]
.split_whitespace()
.nth(19)
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::other("process stat has no valid start time"))
}
fn require_host_pid_namespace(host_pid: u32) -> io::Result<()> {
let status = std::fs::read_to_string(format!("/proc/{host_pid}/status"))?;
let nspid = status
.lines()
.find_map(|line| line.strip_prefix("NSpid:"))
.ok_or_else(|| io::Error::other("process status has no PID namespace mapping"))?;
if nspid.trim() == host_pid.to_string() {
Ok(())
} else {
Err(io::Error::other(
"PID locator is not unambiguous in the host PID namespace",
))
}
}
fn require_default_sigchld() -> io::Result<()> {
let status = std::fs::read_to_string("/proc/self/status")?;
let ignored = signal_mask(&status, "SigIgn:")?;
let caught = signal_mask(&status, "SigCgt:")?;
let sigchld = 1_u128 << (17 - 1);
if ignored & sigchld == 0 && caught & sigchld == 0 {
Ok(())
} else {
Err(io::Error::other("SIGCHLD is not at default disposition"))
}
}
#[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()
.find_map(|line| line.strip_prefix(name))
.and_then(|value| u128::from_str_radix(value.trim(), 16).ok())
.ok_or_else(|| io::Error::other("process status has no valid signal mask"))
}
/// # Errors
/// Returns an error when the fail-closed pipe cannot be created.
pub fn launch_barrier() -> io::Result<(BarrierRelease, BarrierWait)> {
let (reader, writer) = pipe_with(PipeFlags::empty())?;
fcntl_setfd(writer.as_fd(), FdFlags::CLOEXEC)?;
Ok((
BarrierRelease(File::from(writer)),
BarrierWait(File::from(reader)),
))
}
#[derive(Debug)]
pub struct BarrierRelease(File);
impl BarrierRelease {
/// # Errors
/// Returns an error unless the complete release token is written.
pub fn release(mut self) -> io::Result<()> {
self.0.write_all(BARRIER_RELEASE)?;
self.0.flush()
}
}
#[derive(Debug)]
pub struct BarrierWait(File);
impl BarrierWait {
#[must_use]
pub fn into_stdio(self) -> Stdio {
Stdio::from(self.0)
}
/// # Errors
/// Returns an error when the inherited standard-input descriptor cannot be duplicated.
pub fn from_stdin() -> io::Result<Self> {
Ok(Self(File::open("/proc/self/fd/0")?))
}
/// # Errors
/// Refuses EOF, partial, altered, or overlong releases.
pub fn wait(mut self) -> io::Result<()> {
let mut release = [0_u8; BARRIER_RELEASE.len()];
self.0.read_exact(&mut release)?;
let mut excess = [0_u8; 1];
if release != *BARRIER_RELEASE || self.0.read(&mut excess)? != 0 {
return Err(io::Error::other("malformed launch-barrier release"));
}
Ok(())
}
}
#[allow(clippy::disallowed_types)]
pub fn append_parent_identity(
command: &mut std::process::Command,
identity: PidfsProcessIdentityV1,
) {
let authority = identity.authority();
command
.arg("--parent-boot")
.arg(hex(authority.boot_id.as_bytes()))
.arg("--parent-device")
.arg(authority.pidfs_st_dev.get().to_string())
.arg("--parent-inode")
.arg(authority.pidfs_st_ino.get().to_string())
.arg("--parent-pid")
.arg(identity.host_pid().get().to_string())
.arg("--parent-start")
.arg(identity.start_time_ticks().get().to_string());
}
/// # Errors
/// Refuses missing, reordered, malformed, or incomplete wrapper arguments.
pub fn parse_wrapper_arguments(
arguments: impl IntoIterator<Item = OsString>,
) -> io::Result<(PidfsProcessIdentityV1, TargetCommand)> {
let mut arguments = arguments.into_iter();
let boot = named_argument(&mut arguments, "--parent-boot")?;
let device = number_argument(&mut arguments, "--parent-device")?;
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_edge = match named_argument(&mut arguments, "--parent-action")?.to_str() {
Some("kill") => ParentEdge::ParentDeathKill,
Some("lifetime-pipe") => ParentEdge::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"));
}
let program = arguments
.next()
.ok_or_else(|| io::Error::other("wrapper target is missing"))?;
let target = TargetCommand {
program,
arguments: arguments.collect(),
parent_edge,
};
let boot = parse_boot_hex(&boot)?;
Ok((
PidfsProcessIdentityV1::new(
PidfsProcessAuthorityV1 {
boot_id: boot,
pidfs_st_dev: PidfsDeviceId::new(device),
pidfs_st_ino: PidfsInodeId::new(inode),
},
HostPidLocator::new(
u32::try_from(pid).map_err(|_| io::Error::other("parent PID is out of range"))?,
),
ProcessStartTimeTicks::new(start),
),
target,
))
}
fn named_argument(
arguments: &mut impl Iterator<Item = OsString>,
name: &str,
) -> io::Result<OsString> {
if arguments.next().as_deref() != Some(OsStr::new(name)) {
return Err(io::Error::other(format!(
"wrapper argument {name} is missing"
)));
}
arguments
.next()
.ok_or_else(|| io::Error::other(format!("wrapper argument {name} has no value")))
}
fn number_argument(arguments: &mut impl Iterator<Item = OsString>, name: &str) -> io::Result<u64> {
named_argument(arguments, name)?
.to_str()
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::other(format!("wrapper argument {name} is invalid")))
}
fn parse_boot_hex(value: &OsStr) -> io::Result<BootId> {
let value = value
.to_str()
.ok_or_else(|| io::Error::other("parent boot ID is not UTF-8"))?;
if value.len() != 32 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(io::Error::other("parent boot ID is invalid"));
}
let mut bytes = [0_u8; 16];
for (index, byte) in bytes.iter_mut().enumerate() {
*byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
.map_err(|_| io::Error::other("parent boot ID is invalid"))?;
}
Ok(BootId::new(bytes))
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes.iter().fold(String::new(), |mut output, byte| {
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
output
})
}
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
#[must_use]
pub fn exec_target(target: TargetCommand) -> io::Error {
use std::os::unix::process::CommandExt;
std::process::Command::new(target.program)
.args(target.arguments)
.exec()
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::os::fd::{AsFd, AsRawFd};
use std::rc::Rc;
use super::*;
#[test]
fn broker_is_process_global() {
assert!(std::ptr::eq(child_broker(), child_broker()));
}
#[test]
fn parent_edges_require_their_exact_stdio_policy() {
assert!(
require_parent_edge_standard_io(
ParentEdge::LifetimePipe,
ChildStandardIoPolicy::PrivateControlWithLifetimePipe,
)
.is_ok()
);
assert!(
require_parent_edge_standard_io(
ParentEdge::LifetimePipe,
ChildStandardIoPolicy::Silenced,
)
.is_err()
);
assert!(
require_parent_edge_standard_io(
ParentEdge::ParentDeathKill,
ChildStandardIoPolicy::Silenced,
)
.is_ok()
);
assert!(
require_parent_edge_standard_io(
ParentEdge::ParentDeathKill,
ChildStandardIoPolicy::PrivateControlWithLifetimePipe,
)
.is_err()
);
}
#[test]
fn barrier_reader_is_inheritable_and_writer_is_close_on_exec() {
let (release, wait) = launch_barrier().unwrap();
let reader_flags = rustix::io::fcntl_getfd(wait.0.as_fd()).unwrap();
let writer_flags = rustix::io::fcntl_getfd(release.0.as_fd()).unwrap();
assert!(!reader_flags.contains(FdFlags::CLOEXEC));
assert!(writer_flags.contains(FdFlags::CLOEXEC));
assert_ne!(wait.0.as_raw_fd(), release.0.as_raw_fd());
}
#[test]
fn boot_id_requires_exact_canonical_uuid_syntax() {
parse_boot_id("00112233-4455-6677-8899-aabbccddeeff\n").unwrap();
for invalid in [
"00112233445566778899aabbccddeeff",
"00112233_4455-6677-8899-aabbccddeeff",
"00112233-4455-6677-8899-aabbccddeeff-extra",
"00112233-4455-6677-8899-aabbccddeefg",
"00112233-4455-6677-8899-aabbccddeeff\n\n",
] {
assert!(parse_boot_id(invalid).is_err(), "accepted {invalid}");
}
}
#[test]
fn recovery_checks_boot_before_pid_lookup() {
let identity = PidfsProcessIdentityV1::new(
PidfsProcessAuthorityV1 {
boot_id: BootId::new([1; 16]),
pidfs_st_dev: PidfsDeviceId::new(1),
pidfs_st_ino: PidfsInodeId::new(1),
},
HostPidLocator::new(1),
ProcessStartTimeTicks::new(1),
);
let opened = Cell::new(false);
let result = recover_process_with(
identity,
|| Ok(BootId::new([2; 16])),
|_| {
opened.set(true);
unreachable!()
},
);
assert!(result.is_err());
assert!(!opened.get());
}
#[test]
fn pidfs_magic_precedes_same_descriptor_stat_and_errors_refuse() {
let file = File::open("/dev/null").unwrap();
let raw = file.as_raw_fd();
let events = Rc::new(std::cell::RefCell::new(Vec::new()));
let filesystem_events = Rc::clone(&events);
let stat_events = Rc::clone(&events);
let authority = authority_from_pidfd_with(
file.as_fd(),
BootId::new([1; 16]),
move |fd| {
filesystem_events
.borrow_mut()
.push(("fstatfs", fd.as_raw_fd()));
Ok(PID_FS_MAGIC)
},
move |fd| {
stat_events.borrow_mut().push(("fstat", fd.as_raw_fd()));
Ok((7, 9))
},
)
.unwrap();
assert_eq!(&*events.borrow(), &[("fstatfs", raw), ("fstat", raw)]);
assert_eq!(authority.pidfs_st_dev.get(), 7);
assert_eq!(authority.pidfs_st_ino.get(), 9);
for filesystem_error in [false, true] {
let stat_called = Cell::new(false);
let result = authority_from_pidfd_with(
file.as_fd(),
BootId::new([1; 16]),
|_| {
if filesystem_error {
Err(io::Error::other("injected fstatfs error"))
} else {
Ok(0)
}
},
|_| {
stat_called.set(true);
Ok((1, 1))
},
);
assert!(result.is_err());
assert!(!stat_called.get());
}
assert!(
authority_from_pidfd_with(
file.as_fd(),
BootId::new([1; 16]),
|_| Ok(PID_FS_MAGIC),
|_| Err(io::Error::other("injected fstat error")),
)
.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);
}
}