orchestrator: Reject empty payloads in poll_stage With len() == 0 the written == total termination check held on the first poll, so an image-less staging reached Ready and could be activated. An empty payload is a caller bug: poll_stage now returns the new UpdateError::EmptyPayload before touching the device. Assisted-by: Claude (Fable 5)
diff --git a/services/orchestrator/capabilities/src/updatable.rs b/services/orchestrator/capabilities/src/updatable.rs index 3d23f0e..7d89f64 100644 --- a/services/orchestrator/capabilities/src/updatable.rs +++ b/services/orchestrator/capabilities/src/updatable.rs
@@ -70,6 +70,11 @@ /// PLDM device requests its own chunks, including retransmits); /// `payload` must serve any in-range read. /// + /// An empty payload is a caller bug: there is no image, so [`Ready`] + /// must not be reachable vacuously. Implementations return + /// [`EmptyPayload`](UpdateError::EmptyPayload) before touching the + /// device or their own staging state. + /// /// Dyn-compatible on purpose: a heterogeneous fleet (flash and PLDM /// devices side by side, the usual board) sits behind /// `&mut dyn Updatable`, so the payload is a trait object and the @@ -134,6 +139,9 @@ Payload(PayloadReadError), /// The device failed or refused the step; staging anew may succeed. Device, + /// `poll_stage` with an empty payload, a caller bug: an empty image + /// must never stage to [`Ready`](StageProgress::Ready). + EmptyPayload, /// `activate` without a staged, [`Ready`](StageProgress::Ready) /// payload — a caller bug. NothingStaged, @@ -144,6 +152,7 @@ match self { UpdateError::Payload(_) => f.write_str("staging pull failed"), UpdateError::Device => f.write_str("device failed the update step"), + UpdateError::EmptyPayload => f.write_str("empty payload"), UpdateError::NothingStaged => f.write_str("nothing staged"), } } @@ -153,7 +162,7 @@ fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { match self { UpdateError::Payload(fault) => Some(fault), - UpdateError::Device | UpdateError::NothingStaged => None, + UpdateError::Device | UpdateError::EmptyPayload | UpdateError::NothingStaged => None, } } } @@ -262,6 +271,9 @@ &mut self, payload: &dyn PayloadSource, ) -> Result<StageProgress, UpdateError> { + if payload.is_empty() { + return Err(UpdateError::EmptyPayload); + } if self.ready { self.abandon(); // a poll after Ready starts a new transfer } @@ -497,6 +509,9 @@ &mut self, payload: &dyn PayloadSource, ) -> Result<StageProgress, UpdateError> { + if payload.is_empty() { + return Err(UpdateError::EmptyPayload); + } if self.ready { self.abandon(); } @@ -613,6 +628,9 @@ &mut self, payload: &dyn PayloadSource, ) -> Result<StageProgress, UpdateError> { + if payload.is_empty() { + return Err(UpdateError::EmptyPayload); + } if self.ready { self.abandon(); } @@ -743,6 +761,41 @@ } #[test] + fn an_empty_payload_is_rejected_and_never_ready() { + // Without the guard, the `written == total` termination check + // holds vacuously on the first poll and an image-less staging + // reaches Ready. + let mut plain = MockDevice::idle(); + let mut pldm = MockPldmDevice::idle(); + let mut flash = MockFlashDevice::idle(); + + let fleet: [&mut dyn Updatable; 3] = [&mut plain, &mut pldm, &mut flash]; + for dev in fleet { + assert_eq!( + dev.poll_stage(&SliceSource(b"")), + Err(UpdateError::EmptyPayload) + ); + assert_eq!(dev.activate(), Err(UpdateError::NothingStaged)); + } + } + + #[test] + fn a_rejected_empty_poll_leaves_a_ready_staging_intact() { + let mut dev = MockDevice::idle(); + stage_all(&mut dev, &SliceSource(b"image")).expect("staging failed"); + + // The guard rejects before touching staging state, so this + // caller bug cannot destroy a completed staging. + assert_eq!( + dev.poll_stage(&SliceSource(b"")), + Err(UpdateError::EmptyPayload) + ); + dev.activate() + .expect("activate after the rejected poll failed"); + assert_eq!(dev.staged, b"image"); + } + + #[test] fn activate_is_idempotent_while_ready() { let mut dev = MockDevice::idle(); stage_all(&mut dev, &SliceSource(b"image")).expect("staging failed");