Files
wireguard-admin/src/service/dbus.rs
T
2026-10-27 22:44:06 +02:00

1283 lines
45 KiB
Rust

//! Bounded asynchronous systemd D-Bus restart adapter.
//!
//! zbus's `async-io` backend owns its socket-driving thread. This module uses
//! a small standard-library executor to poll each caller-side future only
//! until the shared monotonic deadline; it does not use zbus's blocking signal
//! iterator, whose `next` call has no application deadline.
use std::future::{Future, poll_fn};
use std::pin::{Pin, pin};
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread;
use std::time::Instant;
use zbus::connection::Builder;
use zbus::export::futures_core::Stream;
use zbus::message::Type;
use zbus::{Connection, MatchRule, MessageStream, Proxy};
use super::{
JobResult, RestartDeadline, RestartExecution, RestartFailure, RestartJobTracker,
RestartOutcome, RestartPhase, ServiceEvent,
};
const SYSTEMD_NAME: &str = "org.freedesktop.systemd1";
const SYSTEMD_PATH: &str = "/org/freedesktop/systemd1";
const SYSTEMD_MANAGER: &str = "org.freedesktop.systemd1.Manager";
const DBUS_NAME: &str = "org.freedesktop.DBus";
const DBUS_INTERFACE: &str = "org.freedesktop.DBus";
const SYSTEMD_PRIVATE_ADDRESS: &str = "unix:path=/run/systemd/private";
/// Resolve the configured unit's primary ID, request a restart of exactly
/// `configured_unit`, and wait for its matching systemd `JobRemoved` result.
/// The caller owns backup ordering and subsequent runtime verification, and
/// must pass the same deadline to those stages.
/// Restart a configured unit and retain whether a timed-out request was submitted.
pub(crate) fn restart_systemd_unit_report(
configured_unit: &str,
deadline: RestartDeadline,
) -> RestartExecution {
restart_with_transport_report(&mut SystemdBus::default(), configured_unit, deadline)
}
trait RestartTransport {
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure>;
fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure>;
fn resolve_primary_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
fn next_event(&mut self, deadline: RestartDeadline) -> Result<ServiceEvent, RestartFailure>;
}
#[cfg(test)]
fn restart_with_transport(
transport: &mut impl RestartTransport,
configured_unit: &str,
deadline: RestartDeadline,
) -> RestartOutcome {
restart_with_transport_report(transport, configured_unit, deadline).outcome
}
fn restart_with_transport_report(
transport: &mut impl RestartTransport,
configured_unit: &str,
deadline: RestartDeadline,
) -> RestartExecution {
if configured_unit.is_empty() || configured_unit.as_bytes().contains(&0) {
return execution(
RestartOutcome::Failed(RestartFailure::RequestFailed),
false,
RestartPhase::RequestRestart,
);
}
if let Err(failure) = transport.connect(deadline) {
return execution(
RestartOutcome::Failed(failure),
false,
RestartPhase::Connect,
);
}
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
RestartPhase::Connect,
);
}
if let Err(failure) = transport.subscribe(deadline) {
return execution(
RestartOutcome::Failed(failure),
false,
RestartPhase::Subscribe,
);
}
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
RestartPhase::Subscribe,
);
}
let primary_unit = match transport.resolve_primary_unit(configured_unit, deadline) {
Ok(unit) if !unit.is_empty() => unit,
Ok(_) => {
return execution(
RestartOutcome::Failed(RestartFailure::UnitResolutionFailed),
false,
RestartPhase::ResolveUnit,
);
}
Err(failure) => {
return execution(
RestartOutcome::Failed(failure),
false,
RestartPhase::ResolveUnit,
);
}
};
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
RestartPhase::ResolveUnit,
);
}
let job_path = match transport.restart_unit(configured_unit, deadline) {
Ok(path) => path,
Err(failure) => {
let submitted = matches!(
failure,
RestartFailure::WaitTimedOutMayStillComplete
| RestartFailure::BusDisconnected
| RestartFailure::ManagerOwnerChanged
);
return execution(
RestartOutcome::Failed(failure),
submitted,
RestartPhase::RequestRestart,
);
}
};
let mut tracker = RestartJobTracker::new(primary_unit, deadline);
let mut outcome = tracker.job_submitted(job_path, Instant::now());
while outcome == RestartOutcome::Waiting {
let event = match transport.next_event(deadline) {
Ok(event) => event,
Err(failure) => {
return execution(
RestartOutcome::Failed(failure),
true,
RestartPhase::WaitForJob,
);
}
};
outcome = tracker.observe(event, Instant::now());
}
execution(outcome, true, RestartPhase::WaitForJob)
}
fn execution(outcome: RestartOutcome, submitted: bool, phase: RestartPhase) -> RestartExecution {
RestartExecution {
outcome,
submitted,
phase,
}
}
#[derive(Default)]
struct SystemdBus {
connection: Option<Connection>,
private_peer: bool,
manager: Option<Proxy<'static>>,
job_events: Option<JobSignalStream>,
owner_events: Option<zbus::MessageStream>,
}
enum JobSignalStream {
Bus(Box<zbus::proxy::SignalStream<'static>>),
Peer(Box<MessageStream>),
}
/// The small systemd unit API used by the shared resolver. Keeping method
/// selection and canonical-ID lookup here lets synthetic tests exercise the
/// same resolution flow as the production adapter.
trait UnitLoadApi {
type UnitPath;
fn load_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<Self::UnitPath, RestartFailure>;
fn load_state(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
fn canonical_id(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
}
fn resolve_loaded_unit(
api: &mut impl UnitLoadApi,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let unit_path = api.load_unit(configured_unit, deadline)?;
if !unit_is_loaded(&api.load_state(&unit_path, deadline)?) {
return Err(RestartFailure::UnitResolutionFailed);
}
api.canonical_id(&unit_path, deadline)
}
impl UnitLoadApi for SystemdBus {
type UnitPath = zbus::zvariant::OwnedObjectPath;
fn load_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<Self::UnitPath, RestartFailure> {
self.call_manager("LoadUnit", &(configured_unit,), deadline)
.map_err(map_unit_resolution_failure)?
.body()
.deserialize()
.map_err(|_| RestartFailure::UnitResolutionFailed)
}
fn load_state(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
self.unit_property(unit_path, "LoadState", deadline)
}
fn canonical_id(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
self.unit_property(unit_path, "Id", deadline)
}
}
impl SystemdBus {
fn call_manager<B: serde::ser::Serialize + zbus::zvariant::DynamicType>(
&self,
method: &'static str,
body: &B,
deadline: RestartDeadline,
) -> Result<zbus::message::Message, RestartFailure> {
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
let destination = manager_destination(self.private_peer);
drive(
connection.call_method(
destination,
SYSTEMD_PATH,
Some(SYSTEMD_MANAGER),
method,
body,
),
deadline,
)?
.map_err(map_bus_error)
}
fn unit_property(
&self,
unit_path: &zbus::zvariant::OwnedObjectPath,
property: &'static str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
if !self.private_peer {
let unit = drive(
Proxy::new_owned(
connection.clone(),
SYSTEMD_NAME,
unit_path.clone(),
"org.freedesktop.systemd1.Unit",
),
deadline,
)?
.map_err(map_unit_resolution_error)?;
return drive(unit.get_property::<String>(property), deadline)?
.map_err(map_unit_resolution_error);
}
let message = drive(
connection.call_method(
Option::<&str>::None,
unit_path.clone(),
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.freedesktop.systemd1.Unit", property),
),
deadline,
)?
.map_err(map_unit_resolution_error)?;
let value: zbus::zvariant::OwnedValue = message
.body()
.deserialize()
.map_err(|_| RestartFailure::UnitResolutionFailed)?;
String::try_from(value).map_err(|_| RestartFailure::UnitResolutionFailed)
}
}
fn manager_destination(private_peer: bool) -> Option<&'static str> {
(!private_peer).then_some(SYSTEMD_NAME)
}
fn owner_monitoring_enabled(private_peer: bool) -> bool {
!private_peer
}
fn select_connection<T>(
system_bus: Result<T, (RestartFailure, bool)>,
private_peer: impl FnOnce() -> Result<T, RestartFailure>,
) -> Result<(T, bool), RestartFailure> {
match system_bus {
Ok(connection) => Ok((connection, false)),
// Once private fallback is attempted, keep its typed failure: it is
// the selected transport's actionable result.
Err((_failure, true)) => private_peer().map(|connection| (connection, true)),
Err((failure, false)) => Err(failure),
}
}
impl RestartTransport for SystemdBus {
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
let bus_connection = connect_system_bus(deadline);
let ((connection, manager), private_peer) = select_connection(bus_connection, || {
let connection = drive(
Builder::address(SYSTEMD_PRIVATE_ADDRESS)
.map_err(map_bus_error)?
.p2p()
.auth_mechanism(zbus::connection::AuthMechanism::External)
.build(),
deadline,
)?
.map_err(map_private_builder_error)?;
let uid = drive(connection.peer_creds(), deadline)?
.map_err(map_peer_creds_error)?
.unix_user_id();
if !is_root_peer_uid(uid) {
return Err(RestartFailure::PermissionDenied);
}
Ok((connection, None))
})?;
self.private_peer = private_peer;
self.manager = manager;
self.connection = Some(connection);
Ok(())
}
fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::SubscriptionFailed)?;
let job_events = if self.private_peer {
let rule = MatchRule::builder()
.msg_type(Type::Signal)
.path(SYSTEMD_PATH)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.interface(SYSTEMD_MANAGER)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.member("JobRemoved")
.map_err(|_| RestartFailure::SubscriptionFailed)?
.build();
JobSignalStream::Peer(Box::new(
drive(
MessageStream::for_match_rule(rule, connection, Some(8)),
deadline,
)?
.map_err(|_| RestartFailure::SubscriptionFailed)?,
))
} else {
let manager = self
.manager
.as_ref()
.ok_or(RestartFailure::SubscriptionFailed)?;
JobSignalStream::Bus(Box::new(
drive(manager.receive_signal("JobRemoved"), deadline)?
.map_err(|_| RestartFailure::SubscriptionFailed)?,
))
};
// Peer mode uses zbus's local p2p match and must not call the bus daemon.
// The bus mode additionally watches owner changes before Subscribe.
self.job_events = Some(job_events);
if owner_monitoring_enabled(self.private_peer) {
let owner_rule = MatchRule::builder()
.msg_type(Type::Signal)
.sender(DBUS_NAME)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.interface(DBUS_INTERFACE)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.member("NameOwnerChanged")
.map_err(|_| RestartFailure::SubscriptionFailed)?
.add_arg(SYSTEMD_NAME)
.map_err(|_| RestartFailure::SubscriptionFailed)?
.build();
self.owner_events = Some(
drive(
MessageStream::for_match_rule(owner_rule, connection, Some(1)),
deadline,
)?
.map_err(|_| RestartFailure::SubscriptionFailed)?,
);
}
self.call_manager("Subscribe", &(), deadline)?;
Ok(())
}
fn resolve_primary_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
resolve_loaded_unit(self, configured_unit, deadline)
}
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
let request_body = (unit, "replace");
let job_path: zbus::zvariant::OwnedObjectPath = if self.private_peer {
self.call_manager("RestartUnit", &request_body, deadline)?
.body()
.deserialize()
.map_err(|_| RestartFailure::RequestFailed)?
} else {
let manager = self.manager.as_ref().ok_or(RestartFailure::RequestFailed)?;
let owner_events = self
.owner_events
.as_mut()
.ok_or(RestartFailure::BusDisconnected)?;
let request =
manager.call::<_, _, zbus::zvariant::OwnedObjectPath>("RestartUnit", &request_body);
drive(
async {
let mut request = pin!(request);
let mut closed = pin!(connection.closed());
poll_fn(|cx| {
if connection.is_closed() {
return Poll::Ready(Err(RestartFailure::BusDisconnected));
}
if let Poll::Ready(event) =
Stream::poll_next(Pin::new(&mut *owner_events), cx)
{
return Poll::Ready(match event {
None => Err(RestartFailure::BusDisconnected),
Some(Err(error)) => Err(map_bus_error(error)),
Some(Ok(_)) => Err(RestartFailure::ManagerOwnerChanged),
});
}
if let Poll::Ready(result) = Future::poll(request.as_mut(), cx) {
return Poll::Ready(result.map_err(map_bus_error));
}
if Future::poll(closed.as_mut(), cx).is_ready() {
return Poll::Ready(Err(RestartFailure::BusDisconnected));
}
Poll::Pending
})
.await
},
deadline,
)??
};
Ok(job_path.to_string())
}
fn next_event(&mut self, deadline: RestartDeadline) -> Result<ServiceEvent, RestartFailure> {
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
let job_events = self
.job_events
.as_mut()
.ok_or(RestartFailure::BusDisconnected)?;
let mut owner_events = self.owner_events.as_mut();
drive(
async {
let mut closed = pin!(connection.closed());
poll_fn(|cx| {
if connection.is_closed() {
return Poll::Ready(Ok(ServiceEvent::Disconnected));
}
if let Some(owner_events) = owner_events.as_mut()
&& let Poll::Ready(message) = Stream::poll_next(Pin::new(owner_events), cx)
{
return Poll::Ready(match message {
None => Ok(ServiceEvent::Disconnected),
Some(Err(error)) => Err(map_bus_error(error)),
Some(Ok(message)) => {
let decoded: Result<(String, String, String), _> =
message.body().deserialize();
match decoded {
Ok((name, _, _)) if name == SYSTEMD_NAME => {
Ok(ServiceEvent::ManagerOwnerChanged)
}
Ok(_) => Ok(ServiceEvent::JobRemoved {
job_path: String::new(),
unit: String::new(),
result: JobResult::Other,
}),
Err(_) => Err(RestartFailure::RequestFailed),
}
}
});
}
let message = match job_events {
JobSignalStream::Bus(stream) => {
Stream::poll_next(Pin::new(stream), cx).map(|message| message.map(Ok))
}
JobSignalStream::Peer(stream) => Stream::poll_next(Pin::new(stream), cx),
};
if let Poll::Ready(message) = message {
return Poll::Ready(match message {
None => Ok(ServiceEvent::Disconnected),
Some(Err(error)) => Err(map_bus_error(error)),
Some(Ok(message)) => {
let decoded: Result<
(u32, zbus::zvariant::OwnedObjectPath, String, String),
_,
> = message.body().deserialize();
match decoded {
Ok((_, path, unit, result)) => Ok(ServiceEvent::JobRemoved {
job_path: path.to_string(),
unit,
result: map_job_result(&result),
}),
Err(_) => Err(RestartFailure::RequestFailed),
}
}
});
}
if Future::poll(closed.as_mut(), cx).is_ready() {
return Poll::Ready(Ok(ServiceEvent::Disconnected));
}
Poll::Pending
})
.await
},
deadline,
)?
}
}
fn connect_system_bus(
deadline: RestartDeadline,
) -> Result<(Connection, Option<Proxy<'static>>), (RestartFailure, bool)> {
let connection = match drive(Connection::system(), deadline) {
Err(failure) => return Err((failure, false)),
Ok(Err(error)) => {
return Err((map_connect_error(&error), private_fallback_allowed(&error)));
}
Ok(Ok(connection)) => connection,
};
let manager = match drive(
Proxy::new_owned(
connection.clone(),
SYSTEMD_NAME,
SYSTEMD_PATH,
SYSTEMD_MANAGER,
),
deadline,
) {
Err(failure) => return Err((failure, false)),
Ok(Err(error)) => {
return Err((map_connect_error(&error), private_fallback_allowed(&error)));
}
Ok(Ok(manager)) => manager,
};
Ok((connection, Some(manager)))
}
fn unit_is_loaded(load_state: &str) -> bool {
load_state == "loaded"
}
fn map_job_result(result: &str) -> JobResult {
match result {
"done" => JobResult::Done,
"failed" => JobResult::Failed,
"dependency" => JobResult::Dependency,
"timeout" => JobResult::Timeout,
"canceled" => JobResult::Canceled,
"skipped" => JobResult::Skipped,
_ => JobResult::Other,
}
}
fn map_bus_error(error: zbus::Error) -> RestartFailure {
match error {
zbus::Error::MethodError(name, _, _)
if name.as_str() == "org.freedesktop.DBus.Error.AccessDenied"
|| name.as_str() == "org.freedesktop.systemd1.AccessDenied" =>
{
RestartFailure::PermissionDenied
}
zbus::Error::InputOutput(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
RestartFailure::PermissionDenied
}
zbus::Error::InputOutput(_) => RestartFailure::BusDisconnected,
_ => RestartFailure::RequestFailed,
}
}
fn map_connect_error(error: &zbus::Error) -> RestartFailure {
match error {
zbus::Error::Connection(error, _) | zbus::Error::InputOutput(error)
if error.kind() == std::io::ErrorKind::PermissionDenied =>
{
RestartFailure::PermissionDenied
}
zbus::Error::Connection(_, _) | zbus::Error::InputOutput(_) => {
RestartFailure::BusDisconnected
}
// A handshake rejection is an authentication/protocol failure, not an
// unavailable transport. Never bypass it via the private socket.
_ => RestartFailure::RequestFailed,
}
}
fn map_private_builder_error(error: zbus::Error) -> RestartFailure {
map_connect_error(&error)
}
fn private_fallback_allowed(error: &zbus::Error) -> bool {
match error {
zbus::Error::Connection(io, _) | zbus::Error::InputOutput(io) => matches!(
io.kind(),
std::io::ErrorKind::NotFound
| std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::NotConnected
| std::io::ErrorKind::BrokenPipe
),
_ => false,
}
}
fn is_root_peer_uid(uid: Option<u32>) -> bool {
uid == Some(0)
}
fn map_peer_creds_error(error: std::io::Error) -> RestartFailure {
if error.kind() == std::io::ErrorKind::PermissionDenied {
RestartFailure::PermissionDenied
} else {
RestartFailure::BusDisconnected
}
}
fn map_unit_resolution_error(error: zbus::Error) -> RestartFailure {
match map_bus_error(error) {
RestartFailure::PermissionDenied => RestartFailure::PermissionDenied,
RestartFailure::BusDisconnected => RestartFailure::BusDisconnected,
_ => RestartFailure::UnitResolutionFailed,
}
}
fn map_unit_resolution_failure(failure: RestartFailure) -> RestartFailure {
match failure {
RestartFailure::RequestFailed => RestartFailure::UnitResolutionFailed,
other => other,
}
}
struct ThreadWake(thread::Thread);
impl Wake for ThreadWake {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
/// Poll a future on this thread, parking only for the remaining operation
/// budget. zbus's async-io connection executor continues driving socket I/O
/// independently and wakes this thread when the future can make progress.
fn drive<F: Future>(future: F, deadline: RestartDeadline) -> Result<F::Output, RestartFailure> {
let waker = Waker::from(Arc::new(ThreadWake(thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = pin!(future);
loop {
if deadline.is_expired_at(Instant::now()) {
return Err(RestartFailure::WaitTimedOutMayStillComplete);
}
match Future::poll(future.as_mut(), &mut context) {
Poll::Ready(output) => return Ok(output),
Poll::Pending => {
let remaining = deadline.remaining_at(Instant::now());
if remaining.is_zero() {
return Err(RestartFailure::WaitTimedOutMayStillComplete);
}
thread::park_timeout(remaining);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::time::Duration;
struct FakeBus {
calls: Vec<&'static str>,
deadlines: Vec<(&'static str, RestartDeadline)>,
events: VecDeque<ServiceEvent>,
failure: Option<(&'static str, RestartFailure)>,
configured_unit: &'static str,
job_path: &'static str,
primary_unit: &'static str,
}
impl Default for FakeBus {
fn default() -> Self {
Self {
calls: Vec::new(),
deadlines: Vec::new(),
events: VecDeque::new(),
failure: None,
configured_unit: "wg-quick@wg0.service",
job_path: "",
primary_unit: "wg-quick@wg0.service",
}
}
}
impl FakeBus {
fn stage(&mut self, stage: &'static str) -> Result<(), RestartFailure> {
self.calls.push(stage);
match self.failure {
Some((failed_stage, failure)) if failed_stage == stage => Err(failure),
_ => Ok(()),
}
}
}
impl RestartTransport for FakeBus {
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
self.deadlines.push(("connect", deadline));
self.stage("connect")
}
fn subscribe(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
self.deadlines.push(("subscribe", deadline));
self.stage("subscribe")
}
fn resolve_primary_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
assert_eq!(configured_unit, self.configured_unit);
self.deadlines.push(("resolve", deadline));
self.stage("resolve")?;
Ok(self.primary_unit.to_owned())
}
fn restart_unit(
&mut self,
unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
assert_eq!(unit, self.configured_unit);
self.deadlines.push(("restart", deadline));
self.stage("restart")?;
Ok(self.job_path.to_owned())
}
fn next_event(
&mut self,
deadline: RestartDeadline,
) -> Result<ServiceEvent, RestartFailure> {
self.deadlines.push(("event", deadline));
self.stage("event")?;
self.events
.pop_front()
.ok_or(RestartFailure::BusDisconnected)
}
}
fn deadline() -> RestartDeadline {
RestartDeadline::new(Instant::now(), Duration::from_secs(2))
}
#[test]
fn transport_selection_falls_back_only_before_restart_setup() {
let (selected, private) =
select_connection(Ok("bus"), || Ok("private")).expect("available bus transport");
assert_eq!(selected, "bus");
assert!(!private);
let (selected, private) = select_connection(
Err::<&str, _>((RestartFailure::BusDisconnected, true)),
|| Ok("private"),
)
.expect("private peer fallback");
assert_eq!(selected, "private");
assert!(private);
assert_eq!(manager_destination(true), None);
assert_eq!(manager_destination(false), Some(SYSTEMD_NAME));
assert!(!owner_monitoring_enabled(true));
assert!(owner_monitoring_enabled(false));
let private_denied =
select_connection::<&str>(Err((RestartFailure::BusDisconnected, true)), || {
Err(RestartFailure::PermissionDenied)
});
assert_eq!(private_denied, Err(RestartFailure::PermissionDenied));
let private_timeout =
select_connection::<&str>(Err((RestartFailure::BusDisconnected, true)), || {
Err(RestartFailure::WaitTimedOutMayStillComplete)
});
assert_eq!(
private_timeout,
Err(RestartFailure::WaitTimedOutMayStillComplete)
);
let mut private_attempted = false;
let denied_bus =
select_connection::<&str>(Err((RestartFailure::PermissionDenied, false)), || {
private_attempted = true;
Ok("private")
});
assert_eq!(denied_bus, Err(RestartFailure::PermissionDenied));
assert!(!private_attempted);
let timeout_bus = select_connection::<&str>(
Err((RestartFailure::WaitTimedOutMayStillComplete, false)),
|| {
private_attempted = true;
Ok("private")
},
);
assert_eq!(
timeout_bus,
Err(RestartFailure::WaitTimedOutMayStillComplete)
);
assert!(!private_attempted);
}
#[test]
fn private_fallback_is_limited_to_missing_or_refused_bus_endpoint() {
let unavailable = zbus::Error::Connection(
Arc::new(std::io::Error::from(std::io::ErrorKind::ConnectionRefused)),
zbus::Address::try_from("unix:path=/run/dbus/system_bus_socket")
.expect("valid synthetic bus address"),
);
assert!(private_fallback_allowed(&unavailable));
assert_eq!(
map_connect_error(&unavailable),
RestartFailure::BusDisconnected
);
assert!(private_fallback_allowed(&zbus::Error::InputOutput(
Arc::new(std::io::Error::from(std::io::ErrorKind::ConnectionReset))
)));
let denied = zbus::Error::Connection(
Arc::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
zbus::Address::try_from("unix:path=/run/dbus/system_bus_socket")
.expect("valid synthetic bus address"),
);
assert!(!private_fallback_allowed(&denied));
assert_eq!(map_connect_error(&denied), RestartFailure::PermissionDenied);
assert_eq!(
map_private_builder_error(denied),
RestartFailure::PermissionDenied
);
assert!(!private_fallback_allowed(&zbus::Error::InputOutput(
Arc::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
)));
assert!(!private_fallback_allowed(&zbus::Error::Handshake(
"synthetic authentication rejection".into()
)));
assert!(!is_root_peer_uid(None));
assert!(!is_root_peer_uid(Some(1000)));
assert!(is_root_peer_uid(Some(0)));
assert_eq!(
map_peer_creds_error(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
RestartFailure::PermissionDenied
);
assert_eq!(
map_peer_creds_error(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
RestartFailure::BusDisconnected
);
}
fn removed(path: &str, unit: &str, result: JobResult) -> ServiceEvent {
ServiceEvent::JobRemoved {
job_path: path.to_owned(),
unit: unit.to_owned(),
result,
}
}
#[test]
fn subscribes_before_restart_and_uses_returned_job_path() {
let mut fake = FakeBus {
job_path: "/job/actual",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([
removed("/job/other", "wg-quick@wg0.service", JobResult::Done),
removed("/job/actual", "wg-quick@wg0.service", JobResult::Done),
]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()),
RestartOutcome::Succeeded
);
assert_eq!(
fake.calls,
[
"connect",
"subscribe",
"resolve",
"restart",
"event",
"event"
]
);
}
#[test]
fn fast_completion_and_failure_results_are_processed_without_a_bus() {
let mut fast = FakeBus {
job_path: "/job/fast",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([removed(
"/job/fast",
"wg-quick@wg0.service",
JobResult::Done,
)]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fast, "wg-quick@wg0.service", deadline()),
RestartOutcome::Succeeded
);
let mut failed = FakeBus {
job_path: "/job/failed",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([removed(
"/job/failed",
"wg-quick@wg0.service",
JobResult::Canceled,
)]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut failed, "wg-quick@wg0.service", deadline()),
RestartOutcome::Failed(RestartFailure::JobCanceled)
);
}
#[test]
fn setup_and_permission_failures_stop_before_restart() {
for (stage, failure) in [
("connect", RestartFailure::BusDisconnected),
("subscribe", RestartFailure::SubscriptionFailed),
("resolve", RestartFailure::UnitResolutionFailed),
("restart", RestartFailure::PermissionDenied),
] {
let mut fake = FakeBus {
job_path: "/job/never",
primary_unit: "wg-quick@wg0.service",
failure: Some((stage, failure)),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()),
RestartOutcome::Failed(failure)
);
if stage != "restart" {
assert!(!fake.calls.contains(&"restart"));
}
}
}
#[test]
fn resolves_alias_before_restart_and_uses_one_shared_deadline() {
let deadline = deadline();
let mut fake = FakeBus {
job_path: "/job/alias",
configured_unit: "wireguard-vpn.service",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([removed(
"/job/alias",
"wg-quick@wg0.service",
JobResult::Done,
)]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wireguard-vpn.service", deadline),
RestartOutcome::Succeeded
);
assert_eq!(
fake.calls,
["connect", "subscribe", "resolve", "restart", "event"]
);
assert!(fake.deadlines.iter().all(|(_, passed)| *passed == deadline));
}
#[test]
fn systemd_load_states_allow_only_loaded_units_to_reach_restart() {
assert!(unit_is_loaded("loaded"));
for state in ["not-found", "bad-setting", "error", "masked", "stub"] {
assert!(!unit_is_loaded(state), "unexpected load state: {state}");
}
}
#[test]
fn unit_resolution_keeps_permission_timeout_and_disconnect_categories() {
assert_eq!(
map_unit_resolution_failure(RestartFailure::RequestFailed),
RestartFailure::UnitResolutionFailed
);
for failure in [
RestartFailure::PermissionDenied,
RestartFailure::WaitTimedOutMayStillComplete,
RestartFailure::BusDisconnected,
] {
assert_eq!(map_unit_resolution_failure(failure), failure);
}
}
struct FakeUnitLoadApi {
calls: Vec<String>,
configured_unit_seen: Option<String>,
load_state: Result<String, RestartFailure>,
canonical_id: Result<String, RestartFailure>,
load_unit_result: Result<String, RestartFailure>,
}
impl UnitLoadApi for FakeUnitLoadApi {
type UnitPath = String;
fn load_unit(
&mut self,
configured_unit: &str,
_deadline: RestartDeadline,
) -> Result<Self::UnitPath, RestartFailure> {
self.calls.push("LoadUnit".to_owned());
self.configured_unit_seen = Some(configured_unit.to_owned());
self.load_unit_result.clone()
}
fn load_state(
&mut self,
unit_path: &Self::UnitPath,
_deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
self.calls.push(format!("LoadState:{unit_path}"));
self.load_state.clone()
}
fn canonical_id(
&mut self,
unit_path: &Self::UnitPath,
_deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
self.calls.push(format!("Id:{unit_path}"));
self.canonical_id.clone()
}
}
impl FakeUnitLoadApi {
fn loaded(unit_path: &str, canonical_id: &str) -> Self {
Self {
calls: Vec::new(),
configured_unit_seen: None,
load_state: Ok("loaded".to_owned()),
canonical_id: Ok(canonical_id.to_owned()),
load_unit_result: Ok(unit_path.to_owned()),
}
}
}
#[test]
fn production_unit_resolver_loads_unloaded_alias_and_returns_canonical_id() {
let mut resolver = FakeUnitLoadApi::loaded(
"/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
"wg-quick@wg0.service",
);
let canonical = resolve_loaded_unit(&mut resolver, "wireguard-vpn.service", deadline())
.expect("LoadUnit loads existing unloaded alias");
assert_eq!(
resolver.configured_unit_seen.as_deref(),
Some("wireguard-vpn.service")
);
assert_eq!(canonical, "wg-quick@wg0.service");
assert_eq!(
resolver.calls,
[
"LoadUnit",
"LoadState:/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
"Id:/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
]
);
}
#[test]
fn production_unit_resolver_stops_for_missing_units_and_access_denial() {
let mut missing = FakeUnitLoadApi::loaded("/unit/missing", "unused");
missing.load_state = Ok("not-found".to_owned());
assert_eq!(
resolve_loaded_unit(&mut missing, "missing.service", deadline()),
Err(RestartFailure::UnitResolutionFailed)
);
assert_eq!(missing.calls, ["LoadUnit", "LoadState:/unit/missing"]);
let mut load_error = FakeUnitLoadApi::loaded("/unit/unused", "unused");
load_error.load_unit_result = Err(RestartFailure::UnitResolutionFailed);
assert_eq!(
resolve_loaded_unit(&mut load_error, "absent.service", deadline()),
Err(RestartFailure::UnitResolutionFailed)
);
assert_eq!(load_error.calls, ["LoadUnit"]);
let mut denied = FakeUnitLoadApi::loaded("/unit/denied", "unused");
denied.load_unit_result = Err(RestartFailure::PermissionDenied);
assert_eq!(
resolve_loaded_unit(&mut denied, "restricted.service", deadline()),
Err(RestartFailure::PermissionDenied)
);
assert_eq!(denied.calls, ["LoadUnit"]);
}
#[test]
fn alias_resolution_and_access_denial_stop_before_restart() {
let mut alias = FakeBus {
configured_unit: "wireguard-vpn.service",
primary_unit: "wg-quick@wg0.service",
job_path: "/job/alias",
events: VecDeque::from([removed(
"/job/alias",
"wg-quick@wg0.service",
JobResult::Done,
)]),
..FakeBus::default()
};
let execution =
restart_with_transport_report(&mut alias, "wireguard-vpn.service", deadline());
assert_eq!(execution.outcome, RestartOutcome::Succeeded);
assert_eq!(execution.phase, RestartPhase::WaitForJob);
assert_eq!(
alias.calls[0..4],
["connect", "subscribe", "resolve", "restart"]
);
let mut denied = FakeBus {
failure: Some(("resolve", RestartFailure::PermissionDenied)),
..FakeBus::default()
};
let denied_unit = denied.configured_unit;
let execution = restart_with_transport_report(&mut denied, denied_unit, deadline());
assert_eq!(
execution.outcome,
RestartOutcome::Failed(RestartFailure::PermissionDenied)
);
assert_eq!(execution.phase, RestartPhase::ResolveUnit);
assert!(!denied.calls.contains(&"restart"));
}
#[test]
fn owner_change_while_restart_reply_is_pending_is_reported_as_submitted() {
let mut fake = FakeBus {
failure: Some(("restart", RestartFailure::ManagerOwnerChanged)),
..FakeBus::default()
};
let execution =
restart_with_transport_report(&mut fake, "wg-quick@wg0.service", deadline());
assert_eq!(
execution.outcome,
RestartOutcome::Failed(RestartFailure::ManagerOwnerChanged)
);
assert_eq!(execution.phase, RestartPhase::RequestRestart);
assert!(execution.submitted);
assert_eq!(
fake.calls.iter().filter(|call| **call == "restart").count(),
1
);
}
#[test]
fn expired_shared_deadline_stops_between_setup_and_submission() {
let mut fake = FakeBus {
job_path: "/job/never",
..FakeBus::default()
};
let expired = RestartDeadline::new(Instant::now(), Duration::ZERO);
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", expired),
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete)
);
assert_eq!(fake.calls, ["connect"]);
}
#[test]
fn result_mapping_accepts_only_the_documented_done_value() {
assert_eq!(map_job_result("done"), JobResult::Done);
for value in ["failed", "dependency", "timeout", "canceled", "skipped"] {
assert_ne!(map_job_result(value), JobResult::Done);
}
assert_eq!(map_job_result("future-result"), JobResult::Other);
}
#[test]
fn std_wake_park_driver_rejects_an_already_expired_future() {
let expired = RestartDeadline::new(Instant::now(), Duration::ZERO);
assert_eq!(
drive(std::future::pending::<()>(), expired),
Err(RestartFailure::WaitTimedOutMayStillComplete)
);
}
#[test]
fn owner_change_and_disconnect_fail_the_attempt() {
for (event, failure) in [
(
ServiceEvent::ManagerOwnerChanged,
RestartFailure::ManagerOwnerChanged,
),
(ServiceEvent::Disconnected, RestartFailure::BusDisconnected),
] {
let mut fake = FakeBus {
job_path: "/job/actual",
primary_unit: "wg-quick@wg0.service",
events: VecDeque::from([event]),
..FakeBus::default()
};
assert_eq!(
restart_with_transport(&mut fake, "wg-quick@wg0.service", deadline()),
RestartOutcome::Failed(failure)
);
}
}
}