//! 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; fn restart_unit( &mut self, unit: &str, deadline: RestartDeadline, ) -> Result; fn next_event(&mut self, deadline: RestartDeadline) -> Result; } #[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, private_peer: bool, manager: Option>, job_events: Option, owner_events: Option, } enum JobSignalStream { Bus(Box>), Peer(Box), } /// 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; fn load_state( &mut self, unit_path: &Self::UnitPath, deadline: RestartDeadline, ) -> Result; fn canonical_id( &mut self, unit_path: &Self::UnitPath, deadline: RestartDeadline, ) -> Result; } fn resolve_loaded_unit( api: &mut impl UnitLoadApi, configured_unit: &str, deadline: RestartDeadline, ) -> Result { 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.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 { self.unit_property(unit_path, "LoadState", deadline) } fn canonical_id( &mut self, unit_path: &Self::UnitPath, deadline: RestartDeadline, ) -> Result { self.unit_property(unit_path, "Id", deadline) } } impl SystemdBus { fn call_manager( &self, method: &'static str, body: &B, deadline: RestartDeadline, ) -> Result { 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 { 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::(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( system_bus: Result, private_peer: impl FnOnce() -> Result, ) -> 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 { resolve_loaded_unit(self, configured_unit, deadline) } fn restart_unit( &mut self, unit: &str, deadline: RestartDeadline, ) -> Result { 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 { 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>), (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) -> 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.0.unpark(); } fn wake_by_ref(self: &Arc) { 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(future: F, deadline: RestartDeadline) -> Result { 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, 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 { 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 { 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 { 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, configured_unit_seen: Option, load_state: Result, canonical_id: Result, load_unit_result: Result, } impl UnitLoadApi for FakeUnitLoadApi { type UnitPath = String; fn load_unit( &mut self, configured_unit: &str, _deadline: RestartDeadline, ) -> Result { 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 { self.calls.push(format!("LoadState:{unit_path}")); self.load_state.clone() } fn canonical_id( &mut self, unit_path: &Self::UnitPath, _deadline: RestartDeadline, ) -> Result { 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) ); } } }