diff --git a/services/orchestrator/driver/BUILD.bazel b/services/orchestrator/driver/BUILD.bazel new file mode 100644 index 00000000..b9ee0aa4 --- /dev/null +++ b/services/orchestrator/driver/BUILD.bazel @@ -0,0 +1,28 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +rust_library( + name = "orchestrator_driver", + srcs = [ + "src/board.rs", + "src/driver.rs", + "src/lib.rs", + "src/tests.rs", + ], + crate_name = "openprot_orchestrator_driver", + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//services/orchestrator/sm:orchestrator_sm", + "@rust_crates//:heapless", + ], +) + +# Host tests: build on the host platform, no kernel/QEMU. +rust_test( + name = "orchestrator_driver_test", + crate = ":orchestrator_driver", + edition = "2024", +) diff --git a/services/orchestrator/driver/README.md b/services/orchestrator/driver/README.md new file mode 100644 index 00000000..51e03b3c --- /dev/null +++ b/services/orchestrator/driver/README.md @@ -0,0 +1,27 @@ + + + +# orchestrator platform driver (`openprot_orchestrator_driver`) + +The effect-executing layer around the orchestrator state machine. `PlatformDriver` +implements the SM's `Platform` seam: one method per `Effect`, each +documenting its obligation from the platform-boundary contract +([orchestrator-model.md §6](../../../docs/src/design/orchestrator/orchestrator-model.md)). Unimplemented executors return +`DriverError::NotImplemented`; the SM fail-closes on them. + +Everything device-specific arrives through the seams in `board.rs` +(`ImageSource`, `Verifier`, bundled in `Board`); executor-produced events +return to the SM via `PlatformDriver::take_event`. The event loop dispatches an +outside event, then keeps dispatching what the executors produced until +`take_event` returns `None`: + +```rust +orch.dispatch(&mut driver, event); +while let Some(ev) = driver.take_event() { + orch.dispatch(&mut driver, ev); +} +``` + +Implemented executors: `read_firmware`, `verify_firmware`. Everything else +returns `NotImplemented` until its pillar lands (boot walk, recovery, +update path, attestation, reporting). diff --git a/services/orchestrator/driver/src/board.rs b/services/orchestrator/driver/src/board.rs new file mode 100644 index 00000000..d4bd0665 --- /dev/null +++ b/services/orchestrator/driver/src/board.rs @@ -0,0 +1,121 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! What the board supplies to the driver: traits and wiring data only. +//! Boards (or test mocks) implement these. + +use openprot_orchestrator_sm::ComponentId; + +/// Access to one component's active firmware image, however it is reached — +/// interposed flash, a PLDM/MCTP transfer, a RAM copy in tests. +pub trait ImageSource { + /// The error type reported by this source. + type Error: core::error::Error; + + /// Makes the image readable (claim the flash, open the transfer). + /// Idempotent; a later `open` re-stages the image. + fn open(&mut self) -> Result<(), Self::Error>; + + /// Image length in bytes. + fn size(&mut self) -> Result; + + /// Reads `buf.len()` bytes starting at byte `offset` of the image. + fn read_at(&mut self, offset: usize, buf: &mut [u8]) -> Result<(), Self::Error>; +} + +impl ImageSource for &mut S { + type Error = S::Error; + + #[inline(always)] + fn open(&mut self) -> Result<(), Self::Error> { + (**self).open() + } + + #[inline(always)] + fn size(&mut self) -> Result { + (**self).size() + } + + #[inline(always)] + fn read_at(&mut self, offset: usize, buf: &mut [u8]) -> Result<(), Self::Error> { + (**self).read_at(offset, buf) + } +} + +/// Judges a component's firmware image; board wiring decides what +/// "authentic" means. +pub trait Verifier { + /// The error type reported by this verifier. + type Error: core::error::Error; + + /// Judges `id`'s image, reading it from `image`. + /// + /// # Errors + /// + /// Only when the check could not be performed (crypto fault, missing + /// key, unreadable source). A checked-and-bad image is + /// `Ok(Verdict::Rejected)` — an actuation fault must not forge a + /// verdict. + fn verify( + &mut self, + id: ComponentId, + image: &mut impl ImageSource, + ) -> Result; +} + +impl Verifier for &mut V { + type Error = V::Error; + + #[inline(always)] + fn verify( + &mut self, + id: ComponentId, + image: &mut impl ImageSource, + ) -> Result { + (**self).verify(id, image) + } +} + +/// A [`Verifier`]'s judgment of one image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + /// Reported as `Event::VerificationPassed`. + Authentic, + /// Reported as `Event::VerificationFailed`. + Rejected, +} + +/// One board's type choices, named by a marker type. A new seam adds an +/// associated type here and a field on [`Board`] — never another parameter. +pub trait BoardTypes { + /// Image access for the managed components. + type Image: ImageSource; + /// Judges images for every component. + type Verifier: Verifier; + // Later seams: Reset (release/assert_reset), Evidence (checkpoint + // walk), Recovery, Staging. +} + +/// Everything the board supplies, built once at bring-up and handed to +/// `PlatformDriver::new`. Fields are public: executors may need two parts at once +/// (disjoint borrows). +/// +/// ```ignore +/// struct Ast1060Board; +/// impl BoardTypes for Ast1060Board { +/// type Image = SpiFlashImage; // interposed flash, offsets from the slot layout +/// type Verifier = ManifestVerifier; // signature + SVN via the crypto engine +/// } +/// let board = Board:: { +/// images: [bmc_image, cpld_image], +/// verifier, +/// }; +/// ``` +pub struct Board { + /// `images[i]` belongs to `ComponentId(i)` — device index = chain + /// position = table declaration order. + pub images: [B::Image; N], + /// Judges images for every component. + pub verifier: B::Verifier, + // Later seams add fields, e.g. resets: [B::Reset; N]. +} diff --git a/services/orchestrator/driver/src/driver.rs b/services/orchestrator/driver/src/driver.rs new file mode 100644 index 00000000..94908c39 --- /dev/null +++ b/services/orchestrator/driver/src/driver.rs @@ -0,0 +1,233 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! The [`PlatformDriver`]: one executor method per [`Effect`] variant, routed from +//! the SM through the [`Platform`] impl. + +use openprot_orchestrator_sm::{ComponentId, Effect, EffectError, Event, Platform}; + +use crate::board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; + +/// Queue bound. Executors produce at most one event per effect, the event loop +/// drains it after every dispatch, and the largest SM effect batch today is +/// two (ReadFirmware + VerifyFirmware) — 4 is that worst case with +/// headroom. Overflow is reported ([`DriverError::QueueFull`]), never +/// silent loss. +const EVENT_CAP: usize = 4; + +/// Why the driver could not carry out an effect. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DriverError { + /// The executor for this effect has not been written yet. + NotImplemented, + /// The effect names a component the driver has no device for. + UnknownComponent, + /// The component's image source could not be opened. + ImageUnavailable, + /// Verify was asked for a component whose image was never staged. + NoImage, + /// The verifier could not perform the check (a failed image is a + /// [`Verdict`], not an error). + VerifierFault, + /// The event queue overflowed; dropping events breaks the SM's + /// honest-feedback contract. + QueueFull, +} + +impl core::fmt::Display for DriverError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + DriverError::NotImplemented => "executor not implemented", + DriverError::UnknownComponent => "no device for this component id", + DriverError::ImageUnavailable => "image source could not be opened", + DriverError::NoImage => "no image staged for this component", + DriverError::VerifierFault => "verifier could not perform the check", + DriverError::QueueFull => "event queue full", + }) + } +} + +impl core::error::Error for DriverError {} + +/// The effect executors. Everything device-specific lives in the [`Board`]; +/// the driver's own fields are bookkeeping. +pub struct PlatformDriver { + board: Board, + /// Component whose image is staged (source opened) for verification. + staged: Option, + /// Events awaiting [`take_event`](Self::take_event). + pending: heapless::Deque, +} + +impl PlatformDriver { + pub fn new(board: Board) -> Self { + Self { + board, + staged: None, + pending: heapless::Deque::new(), + } + } + + /// Next event owed to the SM; the event loop drains this after each + /// dispatch. + pub fn take_event(&mut self) -> Option { + self.pending.pop_front() + } + + fn enqueue(&mut self, event: Event) -> Result<(), DriverError> { + self.pending + .push_back(event) + .map_err(|_| DriverError::QueueFull) + } + + /// Stage `id`'s image: open its source so + /// [`verify_firmware`](Self::verify_firmware) can read it. + pub fn stage_firmware(&mut self, id: ComponentId) -> Result<(), DriverError> { + self.staged = None; + let source = self + .board + .images + .get_mut(id.get() as usize) + .ok_or(DriverError::UnknownComponent)?; + source.open().map_err(|_| DriverError::ImageUnavailable)?; + self.staged = Some(id); + Ok(()) + } + + /// Judge the staged image via the [`Verifier`] and queue the verdict: + /// `Event::VerificationPassed(id)` or `Event::VerificationFailed(id)`. + pub fn verify_firmware(&mut self, id: ComponentId) -> Result<(), DriverError> { + if self.staged != Some(id) { + return Err(DriverError::NoImage); + } + let source = self + .board + .images + .get_mut(id.get() as usize) + .ok_or(DriverError::UnknownComponent)?; + let verdict = self + .board + .verifier + .verify(id, source) + .map_err(|_| DriverError::VerifierFault)?; + self.enqueue(match verdict { + Verdict::Authentic => Event::VerificationPassed(id), + Verdict::Rejected => Event::VerificationFailed(id), + }) + } + + /// Release `id` from reset, then walk its boot checkpoints; feed back + /// one `Event::ComponentReady(id)` (Active) or `Event::Booted(id)` + /// (Passive), or `Event::Timeout(id)` on window expiry. + pub fn release_reset(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Hold `id` in reset — a durable quiesce, not a pulse; at-rest + /// verification and the recovery re-walk depend on it. + pub fn assert_reset(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Restore `id` from its configured recovery source (the mechanism is + /// board config); feed back `Event::Restored(id)` or + /// `Event::RecoveryFailed`. + pub fn recover_component(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Authenticate the staged update; feed back `Event::UpdateVerified` or + /// `Event::UpdateRejected`. + pub fn authenticate_update(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Write the incoming update image into the staging region. + pub fn stage_update(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Trial-boot the staged image and arm the commit watchdog; feed back + /// `Event::BootConfirmed(id)` or `Event::CommitTimeout`. + pub fn activate_update(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Discard the staged image. + pub fn discard_staged(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Advance the SVN floor past `id`'s confirmed image; cancels the + /// commit watchdog armed by [`activate_update`](Self::activate_update). + pub fn commit_svn_floor(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Produce a signed attestation for the pending challenge. + pub fn sign_attestation(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Report `id` gated and the platform degraded (CSA degraded-mode + /// clause). + pub fn report_isolated(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Report that `id` exhausted recovery, immediately before the machine + /// latches `Locked`. + pub fn report_recovery_failed(&mut self, _id: ComponentId) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Answer the requester: update declined, machine busy (e.g. a PLDM + /// "retry later" completion code). + pub fn report_update_deferred(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Answer the requester: its in-flight update was superseded by + /// recovery. + pub fn report_update_aborted(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } + + /// Latch the terminal safe state. A failure here is a hard fault: the + /// SM believes it is `Locked`, so the real executor must halt, not + /// recover. + pub fn latch_lockdown(&mut self) -> Result<(), DriverError> { + Err(DriverError::NotImplemented) + } +} + +impl Platform for PlatformDriver { + /// Routes each effect to its executor. Exhaustive: a new [`Effect`] + /// variant must get an executor before this compiles. Every executor + /// error reports as [`EffectError`] — the SM treats all actuation + /// failures the same, fail-closed. + fn execute(&mut self, effect: Effect) -> Result<(), EffectError> { + match effect { + Effect::ReadFirmware(id) => self.stage_firmware(id), + Effect::VerifyFirmware(id) => self.verify_firmware(id), + Effect::ReleaseReset(id) => self.release_reset(id), + Effect::AssertReset(id) => self.assert_reset(id), + Effect::RecoverComponent(id) => self.recover_component(id), + Effect::AuthenticateUpdate => self.authenticate_update(), + Effect::StageUpdate => self.stage_update(), + Effect::ActivateUpdate => self.activate_update(), + Effect::DiscardStaged => self.discard_staged(), + Effect::CommitSvnFloor(id) => self.commit_svn_floor(id), + Effect::SignAttestation => self.sign_attestation(), + Effect::ReportIsolated(id) => self.report_isolated(id), + Effect::ReportRecoveryFailed(id) => self.report_recovery_failed(id), + Effect::ReportUpdateDeferred => self.report_update_deferred(), + Effect::ReportUpdateAborted => self.report_update_aborted(), + Effect::LatchLockdown => self.latch_lockdown(), + // Emit is consumed by the orchestrator; receiving one is a + // driver bug. + Effect::Emit(_) => return Err(EffectError), + } + .map_err(|_| EffectError) + } +} diff --git a/services/orchestrator/driver/src/lib.rs b/services/orchestrator/driver/src/lib.rs new file mode 100644 index 00000000..6be41b8f --- /dev/null +++ b/services/orchestrator/driver/src/lib.rs @@ -0,0 +1,31 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! `openprot_orchestrator_driver` — the effect-executing layer around the +//! orchestrator state machine. +//! +//! [`PlatformDriver`] implements the SM's [`Platform`] seam: one method per `Effect`, +//! each documenting its obligation from the platform-boundary contract +//! (`docs/src/design/orchestrator/orchestrator-model.md` §6). Unimplemented +//! executors return [`DriverError::NotImplemented`]; the SM fail-closes on +//! them. +//! +//! Executor-produced events queue in the driver; the event loop drains them +//! via [`PlatformDriver::take_event`] and dispatches each. +//! +//! Everything device-specific arrives through the seams in [`board`]: +//! image access ([`ImageSource`]) and image judgment ([`Verifier`]), +//! bundled in one [`Board`] built by the board's composition crate. +//! +//! [`Platform`]: openprot_orchestrator_sm::Platform + +#![no_std] +#![forbid(unsafe_code)] + +mod board; +mod driver; +#[cfg(test)] +mod tests; + +pub use board::{Board, BoardTypes, ImageSource, Verdict, Verifier}; +pub use driver::{DriverError, PlatformDriver}; diff --git a/services/orchestrator/driver/src/tests.rs b/services/orchestrator/driver/src/tests.rs new file mode 100644 index 00000000..f203c010 --- /dev/null +++ b/services/orchestrator/driver/src/tests.rs @@ -0,0 +1,293 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +extern crate std; + +use crate::*; +use openprot_orchestrator_sm::{ + ComponentAttrs, ComponentId, Event, Orchestrator, PowerOnResult, State, +}; + +const C0: ComponentId = ComponentId::new(0); + +// Test image convention, shared with the fwmanager itests: 4 magic bytes, +// payload, final byte makes the XOR over the image zero. A board-side +// stand-in for signature + SVN verification. +const IMAGE_MAGIC: [u8; 4] = *b"OPRT"; +const IMAGE_LEN: usize = 16; + +fn valid_image() -> std::vec::Vec { + let mut image = std::vec![0u8; IMAGE_LEN]; + image[..4].copy_from_slice(&IMAGE_MAGIC); + image[4..IMAGE_LEN - 1].fill(0xAB); + image[IMAGE_LEN - 1] = image[..IMAGE_LEN - 1].iter().fold(0, |acc, b| acc ^ b); + image +} + +#[derive(Debug)] +struct MemFault; + +impl core::fmt::Display for MemFault { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("mem image fault") + } +} + +impl core::error::Error for MemFault {} + +/// RAM-backed image source — the seam satisfied without a HAL. +struct MemImage { + data: std::vec::Vec, + fail_open: bool, + fail_read: bool, +} + +impl MemImage { + fn holding(data: std::vec::Vec) -> Self { + Self { + data, + fail_open: false, + fail_read: false, + } + } +} + +impl ImageSource for MemImage { + type Error = MemFault; + + fn open(&mut self) -> Result<(), MemFault> { + if self.fail_open { + return Err(MemFault); + } + Ok(()) + } + + fn size(&mut self) -> Result { + Ok(self.data.len()) + } + + fn read_at(&mut self, offset: usize, buf: &mut [u8]) -> Result<(), MemFault> { + if self.fail_read { + return Err(MemFault); + } + buf.copy_from_slice(&self.data[offset..offset + buf.len()]); + Ok(()) + } +} + +#[derive(Debug)] +struct VerifierBroken; + +impl core::fmt::Display for VerifierBroken { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("verifier broken") + } +} + +impl core::error::Error for VerifierBroken {} + +/// The magic + XOR-zero check as a board-supplied verifier, reading in +/// chunks. +struct XorVerifier { + fault: bool, +} + +impl Verifier for XorVerifier { + type Error = VerifierBroken; + + fn verify( + &mut self, + _id: ComponentId, + image: &mut impl ImageSource, + ) -> Result { + if self.fault { + return Err(VerifierBroken); + } + let len = image.size().map_err(|_| VerifierBroken)?; + let mut magic = [0u8; 4]; + let mut xor = 0u8; + let mut offset = 0; + let mut chunk = [0u8; 4]; + while offset < len { + let take = chunk.len().min(len - offset); + image + .read_at(offset, &mut chunk[..take]) + .map_err(|_| VerifierBroken)?; + if offset == 0 && take >= 4 { + magic.copy_from_slice(&chunk[..4]); + } + xor = chunk[..take].iter().fold(xor, |acc, b| acc ^ b); + offset += take; + } + let ok = len > IMAGE_MAGIC.len() && magic == IMAGE_MAGIC && xor == 0; + Ok(if ok { + Verdict::Authentic + } else { + Verdict::Rejected + }) + } +} + +/// The test board's type choices. +struct MockBoard; + +impl BoardTypes for MockBoard { + type Image = MemImage; + type Verifier = XorVerifier; +} + +fn driver(images: [MemImage; 1]) -> PlatformDriver { + PlatformDriver::new(Board { + images, + verifier: XorVerifier { fault: false }, + }) +} + +fn orchestrator() -> Orchestrator<1, 4> { + let mut chain = heapless::Vec::<_, 1>::new(); + chain + .push((C0, ComponentAttrs::passive_required())) + .unwrap(); + Orchestrator::new(chain.try_into().unwrap(), 3) +} + +// PowerGood drives ReadFirmware + VerifyFirmware into the driver; the +// verdict comes back as an event. +#[test] +fn boot_verifies_the_first_component() { + let mut orch = orchestrator(); + let mut driver = driver([MemImage::holding(valid_image())]); + + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(driver.take_event(), Some(Event::VerificationPassed(C0))); + assert_eq!(driver.take_event(), None); + assert_eq!(orch.state(), State::PreSupervision); +} + +#[test] +fn corrupt_image_fails_verification() { + let mut corrupt = valid_image(); + corrupt[7] ^= 0x01; + let mut driver = driver([MemImage::holding(corrupt)]); + + driver.stage_firmware(C0).unwrap(); + driver.verify_firmware(C0).unwrap(); + + assert_eq!(driver.take_event(), Some(Event::VerificationFailed(C0))); +} + +#[test] +fn verify_without_read_is_refused() { + let mut driver = driver([MemImage::holding(valid_image())]); + + assert_eq!(driver.verify_firmware(C0), Err(DriverError::NoImage)); + assert_eq!(driver.take_event(), None); +} + +// An unopenable source is a failed actuation, not a verdict: the SM +// latches Locked instead of getting a forged VerificationFailed. +#[test] +fn unopenable_source_fails_closed() { + let mut orch = orchestrator(); + let mut image = MemImage::holding(valid_image()); + image.fail_open = true; + let mut driver = driver([image]); + + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(orch.state(), State::Locked); + assert_eq!(driver.take_event(), None); +} + +// A source that opens but cannot be read fails the same way, via the +// verifier's error. +#[test] +fn unreadable_source_fails_closed() { + let mut orch = orchestrator(); + let mut image = MemImage::holding(valid_image()); + image.fail_read = true; + let mut driver = driver([image]); + + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(orch.state(), State::Locked); + assert_eq!(driver.take_event(), None); +} + +// So does a verifier that cannot run its check. +#[test] +fn verifier_fault_fails_closed() { + let mut orch = orchestrator(); + let mut driver = PlatformDriver::::new(Board { + images: [MemImage::holding(valid_image())], + verifier: XorVerifier { fault: true }, + }); + + orch.dispatch(&mut driver, Event::PowerGood(PowerOnResult::Provisioned)); + + assert_eq!(orch.state(), State::Locked); + assert_eq!(driver.take_event(), None); +} + +const C1: ComponentId = ComponentId::new(1); + +#[test] +fn verify_for_a_different_component_is_refused() { + let mut driver = PlatformDriver::::new(Board { + images: [ + MemImage::holding(valid_image()), + MemImage::holding(valid_image()), + ], + verifier: XorVerifier { fault: false }, + }); + + driver.stage_firmware(C0).unwrap(); + + assert_eq!(driver.verify_firmware(C1), Err(DriverError::NoImage)); +} + +#[test] +fn unknown_component_is_refused() { + let mut driver = driver([MemImage::holding(valid_image())]); + + assert_eq!( + driver.stage_firmware(ComponentId::new(9)), + Err(DriverError::UnknownComponent) + ); +} + +// Undrained verdicts eventually fill the queue; the overflow is reported, +// not silently dropped. +#[test] +fn event_queue_overflow_is_reported() { + let mut driver = driver([MemImage::holding(valid_image())]); + + let mut queued = 0; + loop { + driver.stage_firmware(C0).unwrap(); + match driver.verify_firmware(C0) { + Ok(()) => queued += 1, + Err(e) => { + assert_eq!(e, DriverError::QueueFull); + break; + } + } + assert!(queued < 64, "queue never filled"); + } +} + +// Effect::Emit is the orchestrator's internal channel and must never reach +// a Platform; the driver refuses it rather than acting on it. +#[test] +fn emit_is_refused() { + use openprot_orchestrator_sm::{Effect, EffectError, Platform}; + + let mut driver = driver([MemImage::holding(valid_image())]); + + assert_eq!( + driver.execute(Effect::Emit(Event::UpdateRequest)), + Err(EffectError) + ); + assert_eq!(driver.take_event(), None); +}