Implement exact bootstrap recovery and artifact GC

This commit is contained in:
ms2k 2026-07-17 05:38:43 +09:00
commit d25942596f
2 changed files with 1631 additions and 48 deletions

View file

@ -1,12 +1,16 @@
use std::io;
use std::io::{self, IoSlice, IoSliceMut};
use std::mem::MaybeUninit;
use std::os::fd::{AsFd, OwnedFd};
use lazaret_core::codec::{decode, encode};
use rustix::net::{
AddressFamily, RecvFlags, SendFlags, SocketFlags, SocketType, recv, send, socketpair,
AddressFamily, RecvAncillaryBuffer, RecvAncillaryMessage, RecvFlags, ReturnFlags,
SendAncillaryBuffer, SendAncillaryMessage, SendFlags, SocketFlags, SocketType, recv, recvmsg,
send, sendmsg, socketpair,
};
const MAX_PRIVATE_PACKET: usize = 64 * 1024;
const MAX_TRANSFERRED_DESCRIPTORS: usize = 8;
/// # Errors
/// Returns an error when the private sequenced-packet pair cannot be created.
@ -61,13 +65,85 @@ where
decode(&encoded[..received]).map_err(|error| io::Error::other(error.to_string()))
}
/// Sends one nonempty bounded descriptor set with a one-byte packet.
///
/// # Errors
/// Refuses empty, oversized, incomplete, or failed descriptor transfer.
pub fn send_descriptors(
socket: impl AsFd,
descriptors: &[std::os::fd::BorrowedFd<'_>],
) -> io::Result<()> {
if descriptors.is_empty() || descriptors.len() > MAX_TRANSFERRED_DESCRIPTORS {
return Err(io::Error::other("private descriptor set has invalid size"));
}
let mut space =
[MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(MAX_TRANSFERRED_DESCRIPTORS))];
let mut control = SendAncillaryBuffer::new(&mut space);
if !control.push(SendAncillaryMessage::ScmRights(descriptors)) {
return Err(io::Error::other(
"private descriptor control buffer is too small",
));
}
let bytes = [1_u8];
let sent = sendmsg(
socket,
&[IoSlice::new(&bytes)],
&mut control,
SendFlags::empty(),
)?;
if sent == bytes.len() {
Ok(())
} else {
Err(io::Error::other("private descriptor packet was incomplete"))
}
}
/// Receives exactly `expected` descriptors with close-on-exec set atomically.
///
/// # Errors
/// Refuses missing, extra, truncated, malformed, or failed descriptor transfer.
pub fn receive_descriptors(socket: impl AsFd, expected: usize) -> io::Result<Vec<OwnedFd>> {
if expected == 0 || expected > MAX_TRANSFERRED_DESCRIPTORS {
return Err(io::Error::other("expected descriptor count is invalid"));
}
let mut byte = [0_u8];
let mut slices = [IoSliceMut::new(&mut byte)];
let mut space =
[MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(MAX_TRANSFERRED_DESCRIPTORS))];
let mut control = RecvAncillaryBuffer::new(&mut space);
let received = recvmsg(socket, &mut slices, &mut control, RecvFlags::CMSG_CLOEXEC)?;
if received.bytes != 1
|| byte != [1]
|| received
.flags
.intersects(ReturnFlags::TRUNC | ReturnFlags::CTRUNC)
{
return Err(io::Error::other("private descriptor packet is malformed"));
}
let mut descriptors = Vec::new();
for message in control.drain() {
match message {
RecvAncillaryMessage::ScmRights(rights) => descriptors.extend(rights),
_ => return Err(io::Error::other("private ancillary message is unexpected")),
}
}
if descriptors.len() == expected {
Ok(descriptors)
} else {
Err(io::Error::other(
"private descriptor count mismatches request",
))
}
}
#[cfg(test)]
mod tests {
use std::os::fd::AsFd;
use std::fs::File;
use std::os::fd::{AsFd, AsRawFd};
use rustix::net::{RecvFlags, SendFlags, recv, send};
use super::private_seqpacket_pair;
use super::{private_seqpacket_pair, receive_descriptors, send_descriptors};
#[test]
fn preserves_message_boundaries() {
@ -80,4 +156,22 @@ mod tests {
let (_, second) = recv(right.as_fd(), &mut buffer, RecvFlags::empty()).unwrap();
assert_eq!(&buffer[..second], b"second");
}
#[test]
fn transfers_exact_close_on_exec_descriptors() {
let (left, right) = private_seqpacket_pair().unwrap();
let first = File::open("/dev/null").unwrap();
let second = File::open("/dev/zero").unwrap();
send_descriptors(left, &[first.as_fd(), second.as_fd()]).unwrap();
let received = receive_descriptors(right, 2).unwrap();
assert_ne!(received[0].as_raw_fd(), first.as_raw_fd());
assert_ne!(received[1].as_raw_fd(), second.as_raw_fd());
for descriptor in received {
assert!(
rustix::io::fcntl_getfd(&descriptor)
.unwrap()
.contains(rustix::io::FdFlags::CLOEXEC)
);
}
}
}

View file

@ -223,6 +223,18 @@ pub struct RetainedProcess {
identity: PidfsProcessIdentityV1,
}
#[derive(Debug)]
pub enum PidfsRecoveryMatch {
BootMismatch,
Matched(RetainedProcess),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RecoveryActionResult<T> {
BootReconciled(T),
ProcessAction(T),
}
#[derive(Debug)]
pub struct CapturedProcess {
process: RetainedProcess,
@ -412,6 +424,27 @@ impl ChildBroker {
/// 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<ProcessExit> {
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<ProcessExit> {
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<OwnedFd>,
) -> io::Result<ProcessExit> {
let _guard = self
.exclusion
.lock()
@ -426,7 +459,7 @@ impl ChildBroker {
target.append_to(&mut command);
command
.stdin(wait.into_stdio())
.stdout(Stdio::null())
.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()) {
@ -911,41 +944,87 @@ fn fail_after_pidfd_closed<T>(process: RetainedProcess, error: io::Error) -> io:
/// 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)
})
match match_recovery_process(identity)? {
PidfsRecoveryMatch::BootMismatch => Err(io::Error::other(
"stored process authority belongs to another boot",
)),
PidfsRecoveryMatch::Matched(process) => Ok(process),
}
}
fn recover_process_with<Boot, Open>(
/// 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<PidfsRecoveryMatch> {
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<T>(
identity: PidfsProcessIdentityV1,
reconcile_boot: impl FnOnce() -> io::Result<T>,
action: impl FnOnce(&RetainedProcess) -> io::Result<T>,
) -> io::Result<RecoveryActionResult<T>> {
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<Boot, Open, Inspect, Start>(
identity: PidfsProcessIdentityV1,
boot: Boot,
open: Open,
) -> io::Result<RetainedProcess>
inspect: Inspect,
start: Start,
) -> io::Result<PidfsRecoveryMatch>
where
Boot: FnOnce() -> io::Result<BootId>,
Open: FnOnce(u32) -> io::Result<OwnedFd>,
Inspect: FnOnce(BorrowedFd<'_>, BootId) -> io::Result<PidfsProcessAuthorityV1>,
Start: FnOnce(u32) -> io::Result<u64>,
{
let current_boot = boot()?;
if current_boot != identity.authority().boot_id {
return Err(io::Error::other(
"stored process authority belongs to another boot",
));
return Ok(PidfsRecoveryMatch::BootMismatch);
}
let host_pid = identity.host_pid().get();
let pidfd = open(host_pid)?;
let observed = authority_from_pidfd(pidfd.as_fd(), current_boot)?;
let observed = inspect(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() {
if start(host_pid)? != identity.start_time_ticks().get() {
return Err(io::Error::other("process start time mismatch"));
}
Ok(RetainedProcess { pidfd, identity })
Ok(PidfsRecoveryMatch::Matched(RetainedProcess {
pidfd,
identity,
}))
}
/// # Errors
@ -1491,18 +1570,138 @@ mod tests {
ProcessStartTimeTicks::new(1),
);
let opened = Cell::new(false);
let result = recover_process_with(
let result = match_recovery_process_with(
identity,
|| Ok(BootId::new([2; 16])),
|_| {
opened.set(true);
unreachable!()
},
|_, _| unreachable!(),
|_| unreachable!(),
);
assert!(result.is_err());
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();