use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{self, Read, Write}; use std::os::fd::{AsFd, AsRawFd, 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::{OFlags, fcntl_getfl, fcntl_setfl, 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 = OnceLock::new(); #[derive(Debug)] pub struct TargetCommand { program: OsString, arguments: Vec, parent_edge: ParentEdge, executable: Option, stdin: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TargetStdinAccess { ReadOnly, WriteOnly, ReadWrite, } impl TargetStdinAccess { fn from_flags(flags: OFlags) -> io::Result { if flags.contains(OFlags::PATH) { return Err(io::Error::other( "target stdin descriptor has no read or write authority", )); } match flags & OFlags::ACCMODE { flags if flags == OFlags::RDONLY => Ok(Self::ReadOnly), flags if flags == OFlags::WRONLY => Ok(Self::WriteOnly), flags if flags == OFlags::RDWR => Ok(Self::ReadWrite), _ => Err(io::Error::other( "target stdin descriptor has an unsupported access mode", )), } } fn from_descriptor(descriptor: i32) -> io::Result { let metadata = std::fs::read_to_string(format!("/proc/self/fdinfo/{descriptor}"))?; let flags = metadata .lines() .find_map(|line| line.strip_prefix("flags:")) .map(str::trim) .ok_or_else(|| io::Error::other("target stdin descriptor flags are missing")) .and_then(|flags| { u32::from_str_radix(flags, 8) .map(OFlags::from_bits_retain) .map_err(|_| io::Error::other("target stdin descriptor flags are invalid")) })?; Self::from_flags(flags) } fn reopen(self, descriptor: i32) -> io::Result { let mut options = File::options(); match self { Self::ReadOnly => { options.read(true); } Self::WriteOnly => { options.write(true); } Self::ReadWrite => { options.read(true).write(true); } } options.open(format!("/proc/self/fd/{descriptor}")) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ParentEdge { ParentDeathKill, LifetimePipe, } impl TargetCommand { #[must_use] pub fn new(program: impl Into) -> Self { Self { program: program.into(), arguments: Vec::new(), parent_edge: ParentEdge::ParentDeathKill, executable: None, stdin: None, } } /// Constructs an argv-only target bound to the exact inherited executable descriptor. /// /// # Errors /// Returns an error when the descriptor cannot be made inheritable by the launch wrapper. pub fn from_executable( executable: OwnedFd, arguments: impl IntoIterator, ) -> io::Result { fcntl_setfd(executable.as_fd(), FdFlags::empty())?; Ok(Self { program: OsString::from(format!("/proc/self/fd/{}", executable.as_raw_fd())), arguments: arguments.into_iter().collect(), parent_edge: ParentEdge::ParentDeathKill, executable: Some(executable), stdin: None, }) } #[must_use] pub fn argument(mut self, argument: impl Into) -> 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 } /// Attaches the target's standard input after the private wrapper consumes its barrier. /// /// # Errors /// Returns an error when the descriptor cannot be inherited by the wrapper. pub fn with_target_stdin(mut self, stdin: File) -> io::Result { TargetStdinAccess::from_flags(fcntl_getfl(&stdin)?)?; fcntl_setfd(stdin.as_fd(), FdFlags::empty())?; self.stdin = Some(stdin); Ok(self) } #[must_use] pub const fn parent_edge(&self) -> ParentEdge { self.parent_edge } #[must_use] pub fn program(&self) -> &OsStr { &self.program } #[must_use] pub fn arguments(&self) -> &[OsString] { &self.arguments } #[allow(clippy::disallowed_types)] fn append_to(&self, command: &mut std::process::Command) { let _ = &self.executable; command .arg("--parent-action") .arg(match self.parent_edge { ParentEdge::ParentDeathKill => "kill", ParentEdge::LifetimePipe => "lifetime-pipe", }) .args( self.stdin .as_ref() .map(|stdin| { [ OsString::from("--target-stdin-fd"), OsString::from(stdin.as_raw_fd().to_string()), ] }) .into_iter() .flatten(), ) .arg("--") .arg(&self.program) .args(&self.arguments); } } pub struct ChildStandardIo { stdout: Option, stderr: Option, policy: ChildStandardIoPolicy, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ChildStandardIoPolicy { Inherit, PrivateControlWithLifetimePipe, Silenced, Terminal, } 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, lifetime_pipe: impl Into, ) -> 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, } } /// # Errors /// Returns an error when the output descriptor cannot be duplicated for merged terminal I/O. pub fn terminal(output: File) -> io::Result { let error = output.try_clone()?; Ok(Self { stdout: Some(Stdio::from(output)), stderr: Some(Stdio::from(error)), policy: ChildStandardIoPolicy::Terminal, }) } #[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, } pub struct AcknowledgedChildAcquisition<'target> { pub expected_parent: PidfsProcessIdentityV1, pub ownership: ProcessOwnershipContext, pub target: &'target TargetCommand, pub standard_io: ChildStandardIo, } #[derive(Debug)] pub struct RetainedProcess { pidfd: OwnedFd, identity: PidfsProcessIdentityV1, } #[derive(Debug)] pub enum PidfsRecoveryMatch { BootMismatch, Matched(RetainedProcess), } #[derive(Clone, Debug, Eq, PartialEq)] pub enum RecoveryActionResult { BootReconciled(T), ProcessAction(T), } #[derive(Debug)] pub struct CapturedProcess { process: RetainedProcess, stdout: File, stderr: File, } pub struct BootstrapChild { process: RetainedProcess, status: File, } impl BootstrapChild { /// Reads the private bootstrap acquisition status byte. /// /// # Errors /// Returns an error when the child closes or corrupts its status channel. pub fn acquisition_status(&mut self) -> io::Result { let mut status = [0_u8; 1]; self.status.read_exact(&mut status)?; Ok(status[0]) } /// # Errors /// Returns an error when polling the exact child pidfd fails. pub fn has_exited(&self) -> io::Result { self.process.has_exited() } /// Reaps the exact retained child through its pidfd. /// /// # Errors /// Returns an error when serialized exact reaping fails. pub fn reap(self) -> io::Result { child_broker().reap_status(self.process) } } impl CapturedProcess { #[must_use] pub const fn process(&self) -> &RetainedProcess { &self.process } pub fn streams(&mut self) -> (&mut File, &mut File) { (&mut self.stdout, &mut self.stderr) } #[must_use] pub fn into_process(self) -> RetainedProcess { self.process } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ProcessExit { pub exit_code: Option, pub terminating_signal: Option, } 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 { 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 | ChildStandardIoPolicy::Terminal, ) => 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", )) } } } fn require_captured_parent_edge(parent_edge: ParentEdge) -> io::Result<()> { if parent_edge == ParentEdge::ParentDeathKill { Ok(()) } else { Err(io::Error::other( "captured output requires the parent-death edge", )) } } impl ChildBroker { /// Spawns the private Host Coordinator bootstrap with exact pipe ownership and pidfd reaping. /// /// # Errors /// Refuses a command without the lifetime-pipe edge or any spawn, pidfd, or status failure. #[allow(clippy::disallowed_methods, clippy::disallowed_types)] pub fn spawn_bootstrap( &self, target: &TargetCommand, lifetime_reader: File, ) -> io::Result { let _guard = self .exclusion .lock() .map_err(|_| io::Error::other("child broker lock poisoned"))?; if target.parent_edge() != ParentEdge::LifetimePipe { return Err(io::Error::other( "bootstrap child requires the lifetime-pipe edge", )); } require_default_sigchld()?; require_reapable_sigchld()?; let mut command = std::process::Command::new(&target.program); command .args(&target.arguments) .stdin(Stdio::from(lifetime_reader)) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); let mut child = command.spawn()?; let status = child .stdout .take() .ok_or_else(|| io::Error::other("bootstrap status pipe is absent"))?; let process = match acquire_spawned_child(child.id()) { Ok(process) => process, Err(error) => { let _ = child.kill(); let _ = child.wait(); return Err(error); } }; drop(child); Ok(BootstrapChild { process, status: File::from(OwnedFd::from(status)), }) } /// Runs a non-durable direct child under the same spawn/reap exclusion as owned children. /// /// # Errors /// Returns an error when causal launch, exact waiting, or the command exit status fails. #[allow(clippy::disallowed_methods, clippy::disallowed_types)] pub fn run_transient(&self, target: &TargetCommand) -> io::Result { self.run_transient_with_stdout(target, None) } /// Runs a non-durable direct child with one private inherited stdout descriptor. /// /// # Errors /// Returns an error when causal launch, exact waiting, or the command exit status fails. pub fn run_transient_with_private_stdout( &self, target: &TargetCommand, stdout: OwnedFd, ) -> io::Result { self.run_transient_with_stdout(target, Some(stdout)) } #[allow(clippy::disallowed_methods, clippy::disallowed_types)] fn run_transient_with_stdout( &self, target: &TargetCommand, stdout: Option, ) -> io::Result { let _guard = self .exclusion .lock() .map_err(|_| io::Error::other("child broker lock poisoned"))?; require_parent_edge_standard_io(target.parent_edge(), ChildStandardIoPolicy::Silenced)?; require_default_sigchld()?; require_reapable_sigchld()?; let parent = current_process_identity()?; let (release, wait) = launch_barrier()?; let mut command = private_role_command(WRAPPER_ROLE); append_parent_identity(&mut command, parent); target.append_to(&mut command); command .stdin(wait.into_stdio()) .stdout(stdout.map_or_else(Stdio::null, Stdio::from)) .stderr(Stdio::null()); let mut child = command.spawn()?; let process = match acquire_spawned_child(child.id()) { Ok(process) => process, Err(error) => return fail_before_pidfd(release, &mut child, error), }; if let Err(error) = release.release() { return fail_after_pidfd_closed(process, error); } drop(child); let status = rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)? .ok_or_else(|| io::Error::other("transient child exited without wait status"))?; let exit = ProcessExit { exit_code: status.exit_status(), terminating_signal: status.terminating_signal(), }; drop(process); if exit.exit_code == Some(0) && exit.terminating_signal.is_none() { Ok(exit) } else { Err(io::Error::other(format!( "transient child failed with exit {:?} and signal {:?}", exit.exit_code, exit.terminating_signal ))) } } /// Starts a non-durable direct child with split captured output and exact retained authority. /// /// # Errors /// Refuses invalid parent-edge or SIGCHLD state and cleans up on spawn, pidfd, barrier, or /// pipe-acquisition failure. #[allow(clippy::disallowed_methods, clippy::disallowed_types)] pub fn run_transient_captured(&self, target: &TargetCommand) -> io::Result { let _guard = self .exclusion .lock() .map_err(|_| io::Error::other("child broker lock poisoned"))?; require_captured_parent_edge(target.parent_edge())?; require_default_sigchld()?; require_reapable_sigchld()?; let parent = current_process_identity()?; let (release, wait) = launch_barrier()?; let mut command = private_role_command(WRAPPER_ROLE); append_parent_identity(&mut command, parent); target.append_to(&mut command); command .stdin(wait.into_stdio()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn()?; let process = match acquire_spawned_child(child.id()) { Ok(process) => process, Err(error) => return fail_before_pidfd(release, &mut child, error), }; if let Err(error) = release.release() { return fail_after_pidfd_closed(process, error); } captured_process(process, child) } /// 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( &self, expected_parent: PidfsProcessIdentityV1, ownership: ProcessOwnershipContext, target: &TargetCommand, stage: Stage, acknowledge: Acknowledge, ) -> io::Result where Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>, Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result, { 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( &self, expected_parent: PidfsProcessIdentityV1, ownership: ProcessOwnershipContext, target: &TargetCommand, standard_io: ChildStandardIo, stage: Stage, acknowledge: Acknowledge, ) -> io::Result where Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>, Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result, { self.acquire_with_stdio_after_ack( AcknowledgedChildAcquisition { expected_parent, ownership, target, standard_io, }, stage, acknowledge, || Ok(()), ) } /// Acquires an exact child while retaining its blocked wrapper across one acknowledged /// parent transition. /// /// # Errors /// Refuses and reaps the blocked wrapper when staging, acknowledgment, transition, or release /// fails. #[allow(clippy::disallowed_methods, clippy::disallowed_types)] pub fn acquire_with_stdio_after_ack( &self, acquisition: AcknowledgedChildAcquisition<'_>, stage: Stage, acknowledge: Acknowledge, transition: Transition, ) -> io::Result where Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>, Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result, Transition: FnOnce() -> io::Result<()>, { let _guard = self .exclusion .lock() .map_err(|_| io::Error::other("child broker lock poisoned"))?; require_parent_edge_standard_io( acquisition.target.parent_edge(), acquisition.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, acquisition.expected_parent); acquisition.target.append_to(&mut command); command.stdin(wait.into_stdio()); let mut standard_io = acquisition.standard_io; 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( acquisition.ownership.domain_id, acquisition.ownership.generation_id, acquisition.ownership.coordinator_id, acquisition.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) = transition() { return fail_after_pidfd(release, process, error); } if let Err(error) = release.release() { return fail_after_pidfd_closed(process, error); } drop(child); Ok(process) } /// Acquires an exact child with bounded-output pipe ownership retained by the caller. /// /// # Errors /// Refuses and reaps the blocked wrapper on every failed acquisition or callback. #[allow(clippy::disallowed_methods, clippy::disallowed_types)] pub fn acquire_captured( &self, expected_parent: PidfsProcessIdentityV1, ownership: ProcessOwnershipContext, target: &TargetCommand, stage: Stage, acknowledge: Acknowledge, ) -> io::Result where Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>, Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result, { let _guard = self .exclusion .lock() .map_err(|_| io::Error::other("child broker lock poisoned"))?; require_captured_parent_edge(target.parent_edge())?; 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()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn()?; let host_pid = child.id(); let process = match acquire_spawned_child(host_pid) { 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); } captured_process(process, child) } /// # Errors /// Returns an error when serialization or exact pidfd reaping fails. #[allow(clippy::disallowed_methods)] pub fn reap(&self, process: RetainedProcess) -> io::Result<()> { self.reap_status(process).map(|_| ()) } /// # Errors /// Returns an error when serialization or exact pidfd reaping fails. #[allow(clippy::disallowed_methods)] pub fn reap_status(&self, process: RetainedProcess) -> io::Result { let _guard = self .exclusion .lock() .map_err(|_| io::Error::other("child broker lock poisoned"))?; let status = rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)? .ok_or_else(|| io::Error::other("exact child exited without wait status"))?; let exit = ProcessExit { exit_code: status.exit_status(), terminating_signal: status.terminating_signal(), }; drop(process); Ok(exit) } } #[allow(clippy::disallowed_types)] fn captured_process( process: RetainedProcess, mut child: std::process::Child, ) -> io::Result { let Some(stdout) = child.stdout.take() else { return fail_running_captured( process, child, io::Error::other("captured child stdout is unavailable"), ); }; let Some(stderr) = child.stderr.take() else { return fail_running_captured( process, child, io::Error::other("captured child stderr is unavailable"), ); }; let stdout = match child_pipe_file(stdout.into()) { Ok(stdout) => stdout, Err(error) => return fail_running_captured(process, child, error), }; let stderr = match child_pipe_file(stderr.into()) { Ok(stderr) => stderr, Err(error) => return fail_running_captured(process, child, error), }; drop(child); Ok(CapturedProcess { process, stdout, stderr, }) } fn child_pipe_file(fd: OwnedFd) -> io::Result { let flags = fcntl_getfl(fd.as_fd())?; fcntl_setfl(fd.as_fd(), flags | OFlags::NONBLOCK)?; Ok(File::from(fd)) } #[allow(clippy::disallowed_methods, clippy::disallowed_types)] fn fail_running_captured( process: RetainedProcess, child: std::process::Child, error: io::Error, ) -> io::Result { let _ = process.send_signal(Signal::KILL); rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?; drop(child); drop(process); Err(error) } #[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> { 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) -> 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)?; } } if let Some(stdin) = target.stdin.as_ref() { rustix::stdio::dup2_stdin(stdin)?; } Err(exec_target(target)) } fn require_lifetime_pipe_and_recheck( expected_parent: PidfsProcessIdentityV1, ) -> io::Result { 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 { 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 { drop(release); child.wait()?; Err(error) } fn fail_after_pidfd( release: BarrierRelease, process: RetainedProcess, error: io::Error, ) -> io::Result { drop(release); fail_after_pidfd_closed(process, error) } #[allow(clippy::disallowed_methods)] fn fail_after_pidfd_closed(process: RetainedProcess, error: io::Error) -> io::Result { 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 { match match_recovery_process(identity)? { PidfsRecoveryMatch::BootMismatch => Err(io::Error::other( "stored process authority belongs to another boot", )), PidfsRecoveryMatch::Matched(process) => Ok(process), } } /// Reopens one stored process identity or reports boot mismatch before PID lookup. /// /// # Errors /// Refuses host-PID ambiguity, PID lookup, pidfs validation, identity, and negative-evidence /// failures without returning an actionable descriptor. #[allow(clippy::disallowed_methods)] pub fn match_recovery_process(identity: PidfsProcessIdentityV1) -> io::Result { match_recovery_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) }, |pidfd, boot_id| authority_from_pidfd(pidfd, boot_id), read_start_time, ) } /// Reconciles reboot-safe physical evidence or acts through the exactly reopened pidfd. /// /// # Errors /// Propagates reconciliation, pidfs matching, or same-descriptor action failure. The process /// action is never called on boot mismatch or an unequal process identity. pub fn recover_process_action( identity: PidfsProcessIdentityV1, reconcile_boot: impl FnOnce() -> io::Result, action: impl FnOnce(&RetainedProcess) -> io::Result, ) -> io::Result> { match match_recovery_process(identity)? { PidfsRecoveryMatch::BootMismatch => { reconcile_boot().map(RecoveryActionResult::BootReconciled) } PidfsRecoveryMatch::Matched(process) => { action(&process).map(RecoveryActionResult::ProcessAction) } } } fn match_recovery_process_with( identity: PidfsProcessIdentityV1, boot: Boot, open: Open, inspect: Inspect, start: Start, ) -> io::Result where Boot: FnOnce() -> io::Result, Open: FnOnce(u32) -> io::Result, Inspect: FnOnce(BorrowedFd<'_>, BootId) -> io::Result, Start: FnOnce(u32) -> io::Result, { let current_boot = boot()?; if current_boot != identity.authority().boot_id { return Ok(PidfsRecoveryMatch::BootMismatch); } let host_pid = identity.host_pid().get(); let pidfd = open(host_pid)?; let observed = inspect(pidfd.as_fd(), current_boot)?; if observed != identity.authority() { return Err(io::Error::other("pidfs process authority mismatch")); } if start(host_pid)? != identity.start_time_ticks().get() { return Err(io::Error::other("process start time mismatch")); } Ok(PidfsRecoveryMatch::Matched(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 { 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 { 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 { 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( pidfd: impl AsFd, boot_id: BootId, filesystem: Fs, stat: Stat, ) -> io::Result where Fs: FnOnce(std::os::fd::BorrowedFd<'_>) -> io::Result, 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 { 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 { 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 { 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( 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 { 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 { 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, ) -> 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")), }; let next = arguments.next(); let stdin = if next.as_deref() == Some(OsStr::new("--target-stdin-fd")) { let descriptor = arguments .next() .and_then(|value| value.to_str().and_then(|value| value.parse::().ok())) .filter(|descriptor| *descriptor > 2) .ok_or_else(|| io::Error::other("wrapper target stdin descriptor is invalid"))?; let access = TargetStdinAccess::from_descriptor(descriptor)?; if arguments.next().as_deref() != Some(OsStr::new("--")) { return Err(io::Error::other("wrapper target separator is missing")); } Some(access.reopen(descriptor)?) } else if next.as_deref() == Some(OsStr::new("--")) { None } else { 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, executable: None, stdin, }; 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, name: &str, ) -> io::Result { 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, name: &str) -> io::Result { 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 { 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() } /// Stops this process until the host resumes it. /// /// # Errors /// Returns an error when the kernel refuses the stop signal. #[allow(clippy::disallowed_methods)] pub fn suspend_current_process() -> io::Result<()> { Ok(rustix::process::kill_process( rustix::process::getpid(), Signal::STOP, )?) } #[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 bootstrap_child_retains_exact_pipe_owner_and_pidfd_reap() { let (reader, writer) = pipe_with(PipeFlags::CLOEXEC).unwrap(); let target = TargetCommand::new("/bin/sh") .argument("-c") .argument("printf L; read -r _ || exit 0") .with_lifetime_pipe_parent_edge(); let mut child = child_broker() .spawn_bootstrap(&target, File::from(reader)) .unwrap(); assert_eq!(child.acquisition_status().unwrap(), b'L'); assert!(!child.has_exited().unwrap()); drop(writer); let status = child.reap().unwrap(); assert_eq!(status.exit_code, Some(0)); assert_eq!(status.terminating_signal, None); let (reader, _writer) = pipe_with(PipeFlags::CLOEXEC).unwrap(); assert!( child_broker() .spawn_bootstrap(&TargetCommand::new("/bin/true"), File::from(reader)) .is_err() ); } #[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 = match_recovery_process_with( identity, || Ok(BootId::new([2; 16])), |_| { opened.set(true); unreachable!() }, |_, _| unreachable!(), |_| unreachable!(), ); assert!(matches!(result, Ok(PidfsRecoveryMatch::BootMismatch))); assert!(!opened.get()); } #[test] fn recovery_refuses_each_post_open_mismatch_without_returning_authority() { let authority = PidfsProcessAuthorityV1 { boot_id: BootId::new([1; 16]), pidfs_st_dev: PidfsDeviceId::new(7), pidfs_st_ino: PidfsInodeId::new(9), }; let identity = PidfsProcessIdentityV1::new( authority, HostPidLocator::new(41), ProcessStartTimeTicks::new(43), ); for (observed, start) in [ ( PidfsProcessAuthorityV1 { pidfs_st_dev: PidfsDeviceId::new(8), ..authority }, 43, ), ( PidfsProcessAuthorityV1 { pidfs_st_ino: PidfsInodeId::new(10), ..authority }, 43, ), (authority, 44), ] { let events = Rc::new(std::cell::RefCell::new(Vec::new())); let open_events = Rc::clone(&events); let inspect_events = Rc::clone(&events); let start_events = Rc::clone(&events); let result = match_recovery_process_with( identity, || Ok(authority.boot_id), move |pid| { open_events.borrow_mut().push(("open", u64::from(pid))); Ok(File::open("/dev/null").unwrap().into()) }, move |fd, _| { inspect_events .borrow_mut() .push(("inspect", u64::try_from(fd.as_raw_fd()).unwrap())); Ok(observed) }, move |pid| { start_events.borrow_mut().push(("start", u64::from(pid))); Ok(start) }, ); assert!(result.is_err()); assert_eq!(events.borrow()[0], ("open", 41)); assert_eq!(events.borrow()[1].0, "inspect"); if observed == authority { assert_eq!(events.borrow()[2], ("start", 41)); } else { assert_eq!(events.borrow().len(), 2); } } for failure in ["open", "inspect", "start"] { let result = match_recovery_process_with( identity, || Ok(authority.boot_id), |_| { if failure == "open" { Err(io::Error::from_raw_os_error(3)) } else { Ok(File::open("/dev/null").unwrap().into()) } }, |_, _| { if failure == "inspect" { Err(io::Error::other("injected descriptor inspection error")) } else { Ok(authority) } }, |_| { if failure == "start" { Err(io::Error::other("injected start observation error")) } else { Ok(43) } }, ); assert!(result.is_err(), "accepted injected {failure} failure"); } } #[test] fn recovery_action_reconciles_boot_without_process_action() { let mut old_boot = *read_boot_id().unwrap().as_bytes(); old_boot[0] ^= u8::MAX; let identity = PidfsProcessIdentityV1::new( PidfsProcessAuthorityV1 { boot_id: BootId::new(old_boot), pidfs_st_dev: PidfsDeviceId::new(1), pidfs_st_ino: PidfsInodeId::new(1), }, HostPidLocator::new(u32::MAX), ProcessStartTimeTicks::new(u64::MAX), ); let action_called = Cell::new(false); let result = recover_process_action( identity, || Ok("reconciled"), |_| { action_called.set(true); Ok("acted") }, ) .unwrap(); assert_eq!(result, RecoveryActionResult::BootReconciled("reconciled")); assert!(!action_called.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); } }