diff --git a/docs/src/design/orchestrator/orchestrator-overview.md b/docs/src/design/orchestrator/orchestrator-overview.md
index 54f14166f..a005e2b05 100644
--- a/docs/src/design/orchestrator/orchestrator-overview.md
+++ b/docs/src/design/orchestrator/orchestrator-overview.md
@@ -1,4 +1,4 @@
-# Orchestrator State Machine
+# Orchestrator
The orchestrator is the eRoT's boot-sequence controller. It walks the platform
trust chain — verifying each component's firmware and releasing it from reset in
@@ -28,6 +28,10 @@ avoid the two drifting apart.
- [**Platform Architecture**](./orchestrator-platform.md): The platform half around
the core — surrounding services, capability contracts, the board device table,
and the fail-safe rules at the responsibility boundary.
+- [**Runtime**](./orchestrator-runtime.md): How the runtime loop
+ gathers hardware interrupts, IPC channel messages, and watchdog deadlines into
+ the core's event stream, and carries the resulting effects and decisions back
+ out.
## Design Principles
@@ -48,6 +52,22 @@ hiding them as implicit state changes. See the
The platform supplies the trust chain (component ids, kinds, and required/optional
policy) and the recovery-retry cap at startup.
+## Board composition
+
+*Board composition* (or *system composition*) is the per-target choice — described
+declaratively in a [`system.json5`](../../architecture.md) file, assembled by
+Pigweed at build time — of how the platform's functions are split across
+processes and which resources each process owns: hardware register blocks and the
+kernel [interrupt objects and IPC channels](../pw-kernel-ipc.md) built on top of
+them. It is separate from the
+board-supplied *policy* above: policy is *what* to verify (the trust chain);
+composition is *how* the surrounding services are wired. The orchestrator core
+and its platform-agnostic crates are the same across every composition — a driver
+may own a GPIO bank and forward boot-progress over a channel in one image, while
+the orchestrator holds the pins directly in another. That choice changes which
+inbound sources the [runtime](./orchestrator-runtime.md) sees and who owns each
+device, but never the core's states or the loop that serves them.
+
## Relationship to CSA Architecture
The state machine is a direct implementation of the boot sequence described in
diff --git a/docs/src/design/orchestrator/orchestrator-runtime.md b/docs/src/design/orchestrator/orchestrator-runtime.md
new file mode 100644
index 000000000..fbff52c24
--- /dev/null
+++ b/docs/src/design/orchestrator/orchestrator-runtime.md
@@ -0,0 +1,111 @@
+# Runtime
+
+The **runtime** is the orchestrator's event loop: the single task that sits
+between two collaborators — the pure [state machine](./orchestrator-machine.md)
+(the *core*) that decides, and the `PlatformDriver` that executes — turning
+outside happenings (interrupts, timeouts, IPC messages) into the `Event`s the
+core consumes, and handing the `Effect`s it emits to the driver. It holds no
+policy of its own — every decision stays in the core; the runtime only moves
+information across the process boundary.
+
+It gathers three possible inbound sources — **hardware interrupts**
+(boot-progress lines the orchestrator owns directly), **IPC channels**, and
+**watchdog deadlines** (timeouts) — into the one event stream the core
+supervises, and carries the core's decision back out. Which sources are present
+is a [board-composition](./orchestrator-overview.md#board-composition) choice:
+boot-progress is a hardware interrupt only when the orchestrator owns the pins;
+otherwise a monitor forwards it as an IPC message.
+
+The unifying idea is the kernel **wait group**: interrupts and IPC are not
+separate mechanisms but interchangeable *members* of one group, and a watchdog
+deadline is that same wait's *timeout* — so all three collapse into the return
+of one `object_wait(handle, signal_mask, deadline)`. A *signal* here is just a
+named bit on a waitable object, not a source in its own right: a latched IRQ bit
+on an interrupt object, or `READABLE` / `USER` on a channel (a service raises
+`Signals::USER` on a client channel to notify without a reply). The loop is
+written once against "a member that signaled, or the deadline that lapsed,"
+never against a specific source; what differs between sources is only the
+*decoder* that turns each into an `Event`.
+
+Two neighboring pages carry the supporting detail:
+[Platform Architecture](./orchestrator-platform.md) names the services and the
+responsibility boundary, and [pw_kernel IPC](../pw-kernel-ipc.md) gives the
+concrete channel syscalls. The worked, compiling reference is the QEMU
+integration test at `target/ast10x0/tests/orchestrator/runtime/main.rs`.
+
+## The single wait point
+
+The runtime is a single-threaded loop parked in one place: a kernel
+`object_wait` over a **wait group**. Every inbound source is registered once as a
+*member* of that group (`wait_group_add`), and the wait returns whichever member
+signaled. Each member resolves to at most one `Event`:
+
+- **Boot-progress signals** — a component reaching a checkpoint raises a
+ boot-progress signal. Depending on the board's composition it arrives as an
+ interrupt object the orchestrator holds directly or as a message a monitor
+ forwards over a channel; either way the loop maps it to
+ [`Event::ComponentReady`] (an `Active` component's iRoT-verified readiness)
+ or [`Event::Booted`] (a `Passive` component's liveness).
+- **Watchdog deadlines** — the timer is not a separate task. `BootWatchdogs`
+ (`services/orchestrator/server`) folds all armed boot windows and the commit
+ window into a *single deadline* passed straight to `object_wait`. When the
+ wait returns `DeadlineExceeded`, `poll_expired()` yields the mapped
+ [`Event::Timeout`] / [`Event::CommitTimeout`].
+- **IPC channel messages** — an update agent, management path, peer service, or
+ a boot-progress-forwarding monitor holds a channel *initiator*; the runtime
+ holds the *handler*. A readable channel is another object the loop waits on;
+ its message decodes to an `Event` — an [`Event::UpdateRequest`] to answer, or
+ a forwarded [`Event::Booted`] / [`Event::ComponentReady`] notification.
+
+The three share one `object_wait`, so a slow image hash on one path cannot
+delay a boot window on another — this is the "never block" rule of the
+[Platform Architecture](./orchestrator-platform.md#responsibility-scope) made
+concrete: the loop only ever blocks at the wait, and only until the *nearest*
+of any signal, any channel, or the nearest deadline.
+
+**Members are uniform; only the decode differs.** A wait-group member is an
+object watched for a signal bit — a latched IRQ bit on an *interrupt object*, or
+`READABLE` / `USER` on a *channel* — and the loop treats them identically: wait,
+see which member signaled, run that member's decode, dispatch the resulting
+`Event`. Nothing above the decode step knows which kind a member is. That
+uniformity pushes two questions *below* the runtime layer, where they belong:
+
+- **Who owns the underlying hardware** — does the orchestrator own the GPIO bank
+ and hold the boot-progress interrupt object itself, or does a monitor/GPIO
+ server own the pins and forward boot-progress over a channel? — is a
+ [board-composition](./orchestrator-overview.md#board-composition) choice made
+ in `system.json5`. Either shape is just one member of the group; the loop is
+ byte-for-byte the same.
+- **What a member means** is the per-member decode.
+
+```mermaid
+flowchart LR
+ subgraph SRC["Inbound sources (SIG/CH are wait-group members; TMR is the wait's timeout; boot-progress rides CH if a monitor owns the pins)"]
+ SIG["Boot-progress signal
(directly-owned interrupt object, latched)"]
+ TMR["Watchdog deadline
(BootWatchdogs → one Instant)"]
+ CH["IPC channel
(handler endpoint, READABLE)"]
+ end
+
+ WAIT["object_wait(signals, deadline)
the single park point"]
+
+ subgraph MAP["Inbound adapters (source → Event)"]
+ SMAP["signal → ComponentReady / Booted"]
+ TMAP["poll_expired → Timeout / CommitTimeout"]
+ CMAP["channel_read → decode → UpdateRequest / ..."]
+ end
+
+ CORE["Orchestrator::dispatch
(pure reducer)"]
+
+ OUT["Effects → PlatformDriver
+ response → channel_respond"]
+
+ SIG --> WAIT --> SMAP --> CORE
+ TMR --> WAIT --> TMAP --> CORE
+ CH --> WAIT --> CMAP --> CORE
+ CORE --> OUT
+```
+
+[`Event::ComponentReady`]: ./orchestrator-machine.md
+[`Event::Booted`]: ./orchestrator-machine.md
+[`Event::Timeout`]: ./orchestrator-machine.md
+[`Event::CommitTimeout`]: ./orchestrator-machine.md
+[`Event::UpdateRequest`]: ./orchestrator-machine.md
diff --git a/services/orchestrator/update-api/BUILD.bazel b/services/orchestrator/update-api/BUILD.bazel
new file mode 100644
index 000000000..7b09887fe
--- /dev/null
+++ b/services/orchestrator/update-api/BUILD.bazel
@@ -0,0 +1,24 @@
+# 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_update_api",
+ srcs = [
+ "src/lib.rs",
+ "src/status.rs",
+ "src/traits.rs",
+ "src/wire.rs",
+ ],
+ crate_name = "openprot_orchestrator_update_api",
+ edition = "2024",
+ visibility = ["//visibility:public"],
+ deps = ["@rust_crates//:zerocopy"],
+)
+
+# Host tests: build on the host platform, no kernel/QEMU.
+rust_test(
+ name = "orchestrator_update_api_test",
+ crate = ":orchestrator_update_api",
+)
diff --git a/services/orchestrator/update-api/README.md b/services/orchestrator/update-api/README.md
new file mode 100644
index 000000000..a405d323b
--- /dev/null
+++ b/services/orchestrator/update-api/README.md
@@ -0,0 +1,37 @@
+# orchestrator-update-api
+
+The update intake seam between the orchestrator and an update source (the
+PLDM firmware-device service in `services/pldm` today, a self-update path
+later). Contract only: the `UpdateIntake` trait a source calls, the
+`IntakeStatus` phases the orchestrator answers with, and the `wire` encoding
+that carries both over a kernel channel. No transport, no policy, no state,
+so both processes depend on it and it builds and tests on the host.
+
+The source is the channel's initiator and the orchestrator its handler, and
+there is no channel the other way. The orchestrator never waits on the
+source, and every request it answers is one bounded step, so a wedged update
+source cannot delay a boot window or a recovery. Everything the orchestrator
+has to say comes back as the answer to a request the source made: each
+response carries the current phase, and the source reads it with `poll` as
+often as its own protocol needs.
+
+One update, from the source's side:
+
+1. `offer(target, total)` reserves the staging region.
+2. `write(offset, bytes)` fills it, at most 512 bytes per call, in any order.
+3. `complete()` declares the candidate complete, which is what starts the
+ update.
+4. `poll()` until `Activated` or `Failed`.
+
+What backs the staging region is the board's choice. Where the eRoT sits in
+the target's flash path there is no room to hold a copy, so a write goes
+straight into that device's inactive slot and the eRoT authenticates by
+reading the slot back. The phase order follows from that and is not fixed:
+see the `IntakeStatus` docs before assuming one.
+
+`abort()` drops the job from any phase. Activation needs no call: the state
+machine activates on its own verdict once the candidate authenticates.
+
+Run the tests with:
+
+ bazel test //services/orchestrator/update-api:orchestrator_update_api_test
diff --git a/services/orchestrator/update-api/src/lib.rs b/services/orchestrator/update-api/src/lib.rs
new file mode 100644
index 000000000..158728087
--- /dev/null
+++ b/services/orchestrator/update-api/src/lib.rs
@@ -0,0 +1,64 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! The update intake seam between the orchestrator and an update source.
+//!
+//! An update source is the protocol frontend that takes a candidate image
+//! from outside the platform: the PLDM firmware-device service
+//! (`services/pldm`) today, self-update later. This crate is the contract
+//! and nothing else, so both processes depend on it and it builds on the
+//! host: the [`UpdateIntake`] trait a source calls, the [`IntakeStatus`]
+//! phases the orchestrator answers with, and the [`wire`] encoding between
+//! them.
+//!
+//! The source is the kernel channel's initiator and the orchestrator its
+//! handler, with no channel the other way. So the orchestrator never waits
+//! on the source, and handling a request is one bounded step: no request
+//! means "wait until the state machine decides". Everything the
+//! orchestrator decides therefore comes back as the phase on a response,
+//! read as often as the source needs ([`UpdateIntake::poll`]).
+//!
+//! Two consequences, both easy to undo by accident:
+//!
+//! - `Effect::ReportUpdateDeferred` and `Effect::ReportUpdateAborted` latch
+//! a phase, they do not send. The source collects them as
+//! [`FailureCause::Deferred`] / [`FailureCause::Superseded`].
+//! - Polling need not be timer-driven: the handler can raise
+//! `Signals::USER` on the source's channel end
+//! (`syscall::object_set_peer_user_signal`, as `services/i2c` does) when
+//! the phase changes. That is a nudge carrying no data, and the syscall
+//! does not block on the peer.
+//!
+//! What backs the staging region is the board's choice and never the
+//! source's. On a passive device the eRoT sits in the flash path, so the
+//! region is that device's inactive slot and a write goes straight into it;
+//! the eRoT then authenticates by reading the slot back. A board with its
+//! own staging flash copies first instead. Either way the source offers,
+//! writes at offsets, and completes.
+//!
+//! This is the `api` layer of the pattern `services/i2c` and
+//! `services/mctp` established. The orchestrator-side dispatch and the
+//! source-side [`UpdateIntake`] impl over `channel_transact` are separate
+//! crates.
+
+#![cfg_attr(not(test), no_std)]
+#![forbid(unsafe_code)]
+#![warn(missing_docs)]
+
+pub mod status;
+pub mod traits;
+pub mod wire;
+
+pub use status::{FailureCause, IntakeStatus, Progress, Reject};
+pub use traits::{IntakeError, UpdateIntake};
+pub use wire::{Request, Response, UpdateOp};
+
+/// Which managed device a candidate is for: the device's index in the
+/// board's device table, which is what the driver validates an offer
+/// against.
+///
+/// The orchestrator's `ComponentId` stays inside the orchestrator. A source
+/// names a target, and the state machine never sees it at all: it only
+/// decides that an update runs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TargetId(pub u8);
diff --git a/services/orchestrator/update-api/src/status.rs b/services/orchestrator/update-api/src/status.rs
new file mode 100644
index 000000000..49bee0b16
--- /dev/null
+++ b/services/orchestrator/update-api/src/status.rs
@@ -0,0 +1,117 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! What the orchestrator answers: the phase the source polls
+//! ([`IntakeStatus`]), plus one failure vocabulary for a request refused on
+//! the spot ([`Reject`]) and one for a job that ran and failed
+//! ([`FailureCause`]).
+
+/// Bytes moved so far out of the total the offer declared.
+///
+/// The wire form of `StageProgress::Transferring`: `written` is monotonic
+/// and may hold still across polls, so a source watching for a stall keys on
+/// the value, not on the poll count.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Progress {
+ /// Bytes moved so far, at or below `total`.
+ pub written: u64,
+ /// Total payload bytes, from the accepted offer.
+ pub total: u64,
+}
+
+/// The phase of the update in flight.
+///
+/// The source maps it onto what its own protocol owes its peer: a PLDM
+/// firmware device turns [`Receiving`](Self::Receiving) into a transfer in
+/// progress, [`Authenticating`](Self::Authenticating) into `VerifyPending`,
+/// [`Staging`](Self::Staging) into apply progress, and
+/// [`Failed`](Self::Failed) into the matching result code.
+///
+/// The order is not fixed and a source must not assume one. A board that
+/// writes through to the target's inactive slot authenticates by reading
+/// that slot back, so it runs [`Receiving`](Self::Receiving),
+/// [`Authenticating`](Self::Authenticating), [`Activated`](Self::Activated).
+/// A target whose gate is a signed manifest is authenticated before any byte
+/// moves, which swaps the first two.
+///
+/// Intentionally exhaustive: adding a phase is a breaking change, so every
+/// source handles each one.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IntakeStatus {
+ /// No job: none offered, or the last one collected by a fresh offer.
+ Idle,
+ /// The source is writing the candidate into the staging region, which
+ /// on a write-through board is the target's inactive slot. `written`
+ /// counts what the orchestrator took, so a source that lost track can
+ /// resume.
+ Receiving(Progress),
+ /// The orchestrator is authenticating the complete candidate. No
+ /// counters: authentication is one verdict, not a transfer.
+ Authenticating,
+ /// The candidate authenticated; the orchestrator is pushing it to the
+ /// target device, one polled step at a time.
+ ///
+ /// Only a board that stages into its own region ever reports this. Under
+ /// write-through there is nothing left to push, so the phase goes
+ /// straight from [`Authenticating`](Self::Authenticating) to
+ /// [`Activated`](Self::Activated).
+ Staging(Progress),
+ /// The staged image is the device's boot candidate, tentatively. The
+ /// commit gate is orchestrator policy, so this is the last phase a
+ /// successful update shows the source.
+ Activated,
+ /// The job ended without activating. Terminal: it holds until the next
+ /// accepted offer, so a source that polls late still learns the outcome.
+ Failed(FailureCause),
+}
+
+/// Why a job that started did not activate.
+///
+/// Coarse on purpose, mirroring `UpdateError`: the source reports an outcome
+/// and retries or does not, and the orchestrator logs the concrete fault
+/// while it is still in scope.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FailureCause {
+ /// The candidate failed authentication. Not retriable with these bytes.
+ Authentication,
+ /// The state machine was mid-walk, mid-recovery, or mid-update when the
+ /// candidate completed, so it refused the update. The candidate is
+ /// discarded, not judged: offering it again later may succeed.
+ Deferred,
+ /// Recovery preempted the update after it started. Retriable like
+ /// [`Deferred`](Self::Deferred), different cause: the platform took the
+ /// update away, not the timing.
+ Superseded,
+ /// The device or the staging region failed the transfer, or activation
+ /// failed. Offering again may succeed.
+ Device,
+}
+
+/// Why the orchestrator refused a request outright.
+///
+/// A [`Reject`] answers the request itself and never leaves a job
+/// half-started: nothing was written, no phase changed. Contrast
+/// [`FailureCause`], which is a job that ran.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Reject {
+ /// The offer named a target the board's device table does not declare.
+ UnknownTarget,
+ /// An offer while a job is in flight. That job is untouched: a second
+ /// source cannot write into a region that is mid-authenticate.
+ Busy,
+ /// A write or complete with no accepted offer, or after the job left
+ /// [`IntakeStatus::Receiving`].
+ NoJob,
+ /// A write range outside the offered payload, or an offer larger than
+ /// the staging region.
+ OutOfRange,
+ /// An offer of zero bytes, or a complete before every offered byte was
+ /// written. There is no candidate, so authentication must not run.
+ Incomplete,
+ /// The staging write failed. Possibly transient.
+ Storage,
+ /// The request did not decode (see [`wire::WireError`](crate::wire::WireError)).
+ /// Two sides built from this crate never see it; the request side is
+ /// another process and is treated as untrusted.
+ Malformed,
+}
diff --git a/services/orchestrator/update-api/src/traits.rs b/services/orchestrator/update-api/src/traits.rs
new file mode 100644
index 000000000..267809271
--- /dev/null
+++ b/services/orchestrator/update-api/src/traits.rs
@@ -0,0 +1,100 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! The [`UpdateIntake`] seam: what an update source calls on the
+//! orchestrator.
+
+use crate::{IntakeStatus, Reject, TargetId};
+
+/// Why an [`UpdateIntake`] call did not return an answer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IntakeError {
+ /// The orchestrator answered, and the answer is a refusal. The request
+ /// had no effect.
+ Rejected(Reject),
+ /// The call did not reach the orchestrator, or its answer did not decode.
+ /// The request may or may not have taken effect, so a source that cares
+ /// re-reads the phase with [`poll`](UpdateIntake::poll).
+ Transport,
+}
+
+impl core::fmt::Display for IntakeError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ IntakeError::Rejected(_) => f.write_str("the orchestrator refused the request"),
+ IntakeError::Transport => f.write_str("the intake call did not complete"),
+ }
+ }
+}
+
+impl core::error::Error for IntakeError {}
+
+/// The orchestrator's update intake, as an update source calls it.
+///
+/// A source carries bytes and holds no authority: it never names a slot,
+/// never verifies, never decides that an update runs. It offers a candidate,
+/// fills the staging region, says when the candidate is complete, and reports
+/// the outcome to its own peer.
+///
+/// One update: [`offer`](Self::offer) the target and length,
+/// [`write`](Self::write) the payload at whatever offsets the source's
+/// protocol delivers, [`complete`](Self::complete) once every offered byte is
+/// written, then [`poll`](Self::poll) until [`IntakeStatus::Activated`] or
+/// [`IntakeStatus::Failed`]. [`abort`](Self::abort) drops the job from any
+/// phase. Activation needs no call: the state machine activates on its own
+/// verdict once the candidate authenticates.
+///
+/// The source is the channel's initiator and the orchestrator its handler,
+/// with no channel the other way, so the orchestrator never waits on the
+/// source and a wedged source cannot delay a boot window or a recovery. See
+/// the crate docs for what follows from that.
+///
+/// The methods take `&self` so a source can call through a shared reference
+/// from its protocol code (`FdOps` and friends are all `&self`); an
+/// implementation over a kernel channel keeps its buffers in a `RefCell`, as
+/// the MCTP IPC client does.
+pub trait UpdateIntake {
+ /// Offers a candidate of `total` bytes for `target`, reserving the
+ /// staging region.
+ ///
+ /// The orchestrator validates the target against its device table and
+ /// the length against the region. It does not consult the state machine,
+ /// so acceptance is not a promise that the update will run. An offer
+ /// while a job is in flight is [`Reject::Busy`] and leaves that job
+ /// untouched; a zero-length offer is [`Reject::Incomplete`].
+ ///
+ /// A fresh accepted offer collects the previous job's terminal phase.
+ fn offer(&self, target: TargetId, total: u64) -> Result<(), IntakeError>;
+
+ /// Writes `bytes` into the staging region at `offset`, relative to the
+ /// start of the offered payload.
+ ///
+ /// One call is one staging write of at most
+ /// [`MAX_CHUNK`](crate::wire::MAX_CHUNK) bytes. Ranges outside the
+ /// payload are [`Reject::OutOfRange`]; a repeated range overwrites, so a
+ /// retransmitting transfer needs no bookkeeping here.
+ fn write(&self, offset: u64, bytes: &[u8]) -> Result<(), IntakeError>;
+
+ /// Declares the candidate complete, which is what starts the update.
+ ///
+ /// The orchestrator checks that every offered byte was written and hands
+ /// the state machine an update request. The verdict is not part of the
+ /// answer: it arrives through [`poll`](Self::poll). A state machine that
+ /// refuses the request surfaces as
+ /// [`FailureCause::Deferred`](crate::FailureCause::Deferred), not as a
+ /// [`Reject`] here.
+ fn complete(&self) -> Result<(), IntakeError>;
+
+ /// Drops the job: the staging region is released and any in-flight
+ /// staging is abandoned.
+ ///
+ /// Legal in every phase, including with no job, so a source that lost
+ /// track can always get back to [`IntakeStatus::Idle`]. The active image
+ /// is untouched whenever this lands: the staging region is inactive by
+ /// construction.
+ fn abort(&self) -> Result<(), IntakeError>;
+
+ /// Reads the current phase. One bounded read of a latched value, with no
+ /// device or crypto work behind it; cadence is the source's choice.
+ fn poll(&self) -> Result;
+}
diff --git a/services/orchestrator/update-api/src/wire.rs b/services/orchestrator/update-api/src/wire.rs
new file mode 100644
index 000000000..3903e28fb
--- /dev/null
+++ b/services/orchestrator/update-api/src/wire.rs
@@ -0,0 +1,618 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! The intake wire protocol: fixed-size headers over a kernel channel.
+//!
+//! ```text
+//! Request (16-byte header, then the chunk on Write only):
+//! ┌─────┬────────┬──────────┬────────┬────────┬──────────┐
+//! │ op │ target │ reserved │ arg │ len │ reserved │
+//! │ 1B │ 1B │ 2B │ 8B LE │ 2B LE │ 2B │
+//! └─────┴────────┴──────────┴────────┴────────┴──────────┘
+//!
+//! Response (20 bytes, no payload):
+//! ┌──────┬───────┬────────┬──────────┬─────────┬─────────┐
+//! │ code │ phase │ detail │ reserved │ written │ total │
+//! │ 1B │ 1B │ 1B │ 1B │ 8B LE │ 8B LE │
+//! └──────┴───────┴────────┴──────────┴─────────┴─────────┘
+//! ```
+//!
+//! `arg` is the payload length on [`Offer`](UpdateOp::Offer) and the write
+//! offset on [`Write`](UpdateOp::Write); `len` is the chunk length on
+//! [`Write`](UpdateOp::Write). Both are zero on every other op. Reserved
+//! fields are zero everywhere and the handler rejects a request that sets
+//! them, so they can be given a meaning later.
+//!
+//! Every response carries the phase, not just the answer to
+//! [`Poll`](UpdateOp::Poll), so a source acting on each write's outcome sees
+//! a job fail without a second round trip. `written`/`total` are zero
+//! outside the phases that carry [`Progress`], `detail` zero outside
+//! [`IntakeStatus::Failed`].
+//!
+//! The request side is another process and is treated as untrusted:
+//! decoding validates lengths, opcodes, reserved fields and the chunk bound
+//! before anything looks at the payload, and never panics.
+
+use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
+
+use crate::{FailureCause, IntakeStatus, Progress, Reject, TargetId};
+
+/// Largest chunk one [`Write`](UpdateOp::Write) may carry.
+///
+/// Bounds the handler's stack read buffer and the work of one request. 512
+/// matches `FD_MAX_XFER_SIZE` in `pldm-interface`, the largest transfer the
+/// firmware device negotiates, so a `RequestFirmwareData` chunk passes
+/// through without splitting.
+pub const MAX_CHUNK: usize = 512;
+
+/// Request buffer size a handler must provide.
+pub const MAX_REQUEST_SIZE: usize = RequestHeader::SIZE + MAX_CHUNK;
+
+/// Response buffer size an initiator must provide.
+pub const MAX_RESPONSE_SIZE: usize = ResponseHeader::SIZE;
+
+/// Why a buffer did not decode.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WireError {
+ /// Shorter than the header, or shorter than the header plus the chunk
+ /// length the header declares.
+ Truncated,
+ /// The output buffer cannot hold the encoding.
+ BufferTooSmall,
+ /// The op byte names no operation.
+ InvalidOpcode(u8),
+ /// A field carries a value this op does not define: a reserved field
+ /// set, a chunk over [`MAX_CHUNK`], or `arg`/`len` non-zero on an op
+ /// that has no use for them.
+ InvalidField,
+}
+
+impl core::fmt::Display for WireError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ f.write_str(match self {
+ WireError::Truncated => "buffer shorter than the message",
+ WireError::BufferTooSmall => "buffer too small for the message",
+ WireError::InvalidOpcode(_) => "unknown operation code",
+ WireError::InvalidField => "field value not defined for this operation",
+ })
+ }
+}
+
+impl core::error::Error for WireError {}
+
+/// The intake operations, one per [`UpdateIntake`](crate::UpdateIntake)
+/// method.
+///
+/// `#[non_exhaustive]`: the two sides are separate processes and may be built
+/// from different revisions, so an unknown op is a runtime case
+/// ([`WireError::InvalidOpcode`]), not a compile-time one.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+#[non_exhaustive]
+pub enum UpdateOp {
+ /// Offer a candidate and reserve the staging region.
+ Offer = 0,
+ /// Write one chunk into the staging region.
+ Write = 1,
+ /// Declare the candidate complete, starting the update.
+ Complete = 2,
+ /// Drop the job.
+ Abort = 3,
+ /// Read the phase.
+ Poll = 4,
+}
+
+impl TryFrom for UpdateOp {
+ type Error = WireError;
+
+ fn try_from(value: u8) -> Result {
+ match value {
+ 0 => Ok(UpdateOp::Offer),
+ 1 => Ok(UpdateOp::Write),
+ 2 => Ok(UpdateOp::Complete),
+ 3 => Ok(UpdateOp::Abort),
+ 4 => Ok(UpdateOp::Poll),
+ other => Err(WireError::InvalidOpcode(other)),
+ }
+ }
+}
+
+/// The 16-byte request header. Fields are private because the accessors are
+/// what validate them.
+#[repr(C, packed)]
+#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, Immutable, KnownLayout)]
+pub struct RequestHeader {
+ op_code: u8,
+ target: u8,
+ reserved: u16,
+ arg: u64,
+ len: u16,
+ reserved_tail: u16,
+}
+
+impl RequestHeader {
+ /// Encoded size in bytes.
+ pub const SIZE: usize = core::mem::size_of::();
+
+ fn new(op: UpdateOp, target: u8, arg: u64, len: u16) -> Self {
+ Self {
+ op_code: op as u8,
+ target,
+ reserved: 0,
+ arg: arg.to_le(),
+ len: len.to_le(),
+ reserved_tail: 0,
+ }
+ }
+
+ /// The operation, or why the op byte is not one.
+ pub fn op(&self) -> Result {
+ UpdateOp::try_from(self.op_code)
+ }
+
+ /// The target byte, meaningful on [`UpdateOp::Offer`] only.
+ pub fn target(&self) -> TargetId {
+ TargetId(self.target)
+ }
+
+ /// The payload length on [`UpdateOp::Offer`], the write offset on
+ /// [`UpdateOp::Write`], zero otherwise.
+ pub fn arg(&self) -> u64 {
+ u64::from_le(self.arg)
+ }
+
+ /// The chunk length on [`UpdateOp::Write`], zero otherwise.
+ pub fn len(&self) -> u16 {
+ u16::from_le(self.len)
+ }
+
+ fn reserved_are_clear(&self) -> bool {
+ self.reserved == 0 && self.reserved_tail == 0
+ }
+}
+
+/// One decoded request. Borrows the chunk out of the handler's read buffer,
+/// so a staging write needs no second copy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Request<'a> {
+ /// [`UpdateIntake::offer`](crate::UpdateIntake::offer).
+ Offer {
+ /// The device the candidate is for.
+ target: TargetId,
+ /// Payload length in bytes.
+ total: u64,
+ },
+ /// [`UpdateIntake::write`](crate::UpdateIntake::write).
+ Write {
+ /// Offset into the offered payload.
+ offset: u64,
+ /// The chunk, at most [`MAX_CHUNK`] bytes.
+ bytes: &'a [u8],
+ },
+ /// [`UpdateIntake::complete`](crate::UpdateIntake::complete).
+ Complete,
+ /// [`UpdateIntake::abort`](crate::UpdateIntake::abort).
+ Abort,
+ /// [`UpdateIntake::poll`](crate::UpdateIntake::poll).
+ Poll,
+}
+
+impl Request<'_> {
+ /// Encodes into `buf`, returning the encoded length.
+ pub fn encode(&self, buf: &mut [u8]) -> Result {
+ let (header, chunk) = match *self {
+ Request::Offer { target, total } => (
+ RequestHeader::new(UpdateOp::Offer, target.0, total, 0),
+ None,
+ ),
+ Request::Write { offset, bytes } => {
+ let len = u16::try_from(bytes.len()).map_err(|_| WireError::InvalidField)?;
+ if bytes.len() > MAX_CHUNK {
+ return Err(WireError::InvalidField);
+ }
+ (
+ RequestHeader::new(UpdateOp::Write, 0, offset, len),
+ Some(bytes),
+ )
+ }
+ Request::Complete => (RequestHeader::new(UpdateOp::Complete, 0, 0, 0), None),
+ Request::Abort => (RequestHeader::new(UpdateOp::Abort, 0, 0, 0), None),
+ Request::Poll => (RequestHeader::new(UpdateOp::Poll, 0, 0, 0), None),
+ };
+ let chunk = chunk.unwrap_or(&[]);
+ let total = RequestHeader::SIZE + chunk.len();
+ let out = buf.get_mut(..total).ok_or(WireError::BufferTooSmall)?;
+ out[..RequestHeader::SIZE].copy_from_slice(header.as_bytes());
+ out[RequestHeader::SIZE..].copy_from_slice(chunk);
+ Ok(total)
+ }
+
+ /// Decodes one request out of a handler's read buffer, validating header
+ /// length, opcode, reserved fields, the chunk bound, and that fields an
+ /// op does not define are zero.
+ pub fn decode(buf: &[u8]) -> Result, WireError> {
+ let (head, rest) = buf
+ .split_at_checked(RequestHeader::SIZE)
+ .ok_or(WireError::Truncated)?;
+ // Infallible on an exact-size slice; report it as a short buffer.
+ let header = RequestHeader::read_from_bytes(head).map_err(|_| WireError::Truncated)?;
+ if !header.reserved_are_clear() {
+ return Err(WireError::InvalidField);
+ }
+ let op = header.op()?;
+ let arg = header.arg();
+ let len = usize::from(header.len());
+ // Reject rather than ignore a field the op does not define: a
+ // mismatch means the two sides disagree about the protocol.
+ if op != UpdateOp::Write && len != 0 {
+ return Err(WireError::InvalidField);
+ }
+ if matches!(op, UpdateOp::Complete | UpdateOp::Abort | UpdateOp::Poll) && arg != 0 {
+ return Err(WireError::InvalidField);
+ }
+ Ok(match op {
+ UpdateOp::Offer => Request::Offer {
+ target: header.target(),
+ total: arg,
+ },
+ UpdateOp::Write => {
+ if len > MAX_CHUNK {
+ return Err(WireError::InvalidField);
+ }
+ Request::Write {
+ offset: arg,
+ bytes: rest.get(..len).ok_or(WireError::Truncated)?,
+ }
+ }
+ UpdateOp::Complete => Request::Complete,
+ UpdateOp::Abort => Request::Abort,
+ UpdateOp::Poll => Request::Poll,
+ })
+ }
+}
+
+/// The 20-byte response.
+#[repr(C, packed)]
+#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, Immutable, KnownLayout)]
+pub struct ResponseHeader {
+ code: u8,
+ phase: u8,
+ detail: u8,
+ reserved: u8,
+ written: u64,
+ total: u64,
+}
+
+impl ResponseHeader {
+ /// Encoded size in bytes.
+ pub const SIZE: usize = core::mem::size_of::();
+}
+
+/// What the handler answers: the outcome of the request, plus the phase as
+/// of that answer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Response {
+ /// Whether the request took effect. A refusal leaves the phase
+ /// unchanged, so `status` still describes the job already there.
+ pub outcome: Result<(), Reject>,
+ /// The phase as of this answer.
+ pub status: IntakeStatus,
+}
+
+impl Response {
+ /// Encodes into `buf`, returning the encoded length.
+ pub fn encode(&self, buf: &mut [u8]) -> Result {
+ let (phase, detail, progress) = match self.status {
+ IntakeStatus::Idle => (PHASE_IDLE, 0, None),
+ IntakeStatus::Receiving(p) => (PHASE_RECEIVING, 0, Some(p)),
+ IntakeStatus::Authenticating => (PHASE_AUTHENTICATING, 0, None),
+ IntakeStatus::Staging(p) => (PHASE_STAGING, 0, Some(p)),
+ IntakeStatus::Activated => (PHASE_ACTIVATED, 0, None),
+ IntakeStatus::Failed(cause) => (PHASE_FAILED, cause_code(cause), None),
+ };
+ let progress = progress.unwrap_or(Progress {
+ written: 0,
+ total: 0,
+ });
+ let header = ResponseHeader {
+ code: match self.outcome {
+ Ok(()) => CODE_OK,
+ Err(reject) => reject_code(reject),
+ },
+ phase,
+ detail,
+ reserved: 0,
+ written: progress.written.to_le(),
+ total: progress.total.to_le(),
+ };
+ let out = buf
+ .get_mut(..ResponseHeader::SIZE)
+ .ok_or(WireError::BufferTooSmall)?;
+ out.copy_from_slice(header.as_bytes());
+ Ok(ResponseHeader::SIZE)
+ }
+
+ /// Decodes the handler's answer.
+ pub fn decode(buf: &[u8]) -> Result {
+ let head = buf
+ .get(..ResponseHeader::SIZE)
+ .ok_or(WireError::Truncated)?;
+ let header = ResponseHeader::read_from_bytes(head).map_err(|_| WireError::Truncated)?;
+ if header.reserved != 0 {
+ return Err(WireError::InvalidField);
+ }
+ let progress = Progress {
+ written: u64::from_le(header.written),
+ total: u64::from_le(header.total),
+ };
+ let status = match header.phase {
+ PHASE_IDLE => IntakeStatus::Idle,
+ PHASE_RECEIVING => IntakeStatus::Receiving(progress),
+ PHASE_AUTHENTICATING => IntakeStatus::Authenticating,
+ PHASE_STAGING => IntakeStatus::Staging(progress),
+ PHASE_ACTIVATED => IntakeStatus::Activated,
+ PHASE_FAILED => IntakeStatus::Failed(cause_from_code(header.detail)?),
+ _ => return Err(WireError::InvalidField),
+ };
+ let outcome = match header.code {
+ CODE_OK => Ok(()),
+ code => Err(reject_from_code(code)?),
+ };
+ Ok(Response { outcome, status })
+ }
+}
+
+const CODE_OK: u8 = 0;
+
+const PHASE_IDLE: u8 = 0;
+const PHASE_RECEIVING: u8 = 1;
+const PHASE_AUTHENTICATING: u8 = 2;
+const PHASE_STAGING: u8 = 3;
+const PHASE_ACTIVATED: u8 = 4;
+const PHASE_FAILED: u8 = 5;
+
+fn cause_code(cause: FailureCause) -> u8 {
+ match cause {
+ FailureCause::Authentication => 1,
+ FailureCause::Deferred => 2,
+ FailureCause::Superseded => 3,
+ FailureCause::Device => 4,
+ }
+}
+
+fn cause_from_code(code: u8) -> Result {
+ match code {
+ 1 => Ok(FailureCause::Authentication),
+ 2 => Ok(FailureCause::Deferred),
+ 3 => Ok(FailureCause::Superseded),
+ 4 => Ok(FailureCause::Device),
+ _ => Err(WireError::InvalidField),
+ }
+}
+
+fn reject_code(reject: Reject) -> u8 {
+ match reject {
+ Reject::UnknownTarget => 1,
+ Reject::Busy => 2,
+ Reject::NoJob => 3,
+ Reject::OutOfRange => 4,
+ Reject::Incomplete => 5,
+ Reject::Storage => 6,
+ Reject::Malformed => 7,
+ }
+}
+
+fn reject_from_code(code: u8) -> Result {
+ match code {
+ 1 => Ok(Reject::UnknownTarget),
+ 2 => Ok(Reject::Busy),
+ 3 => Ok(Reject::NoJob),
+ 4 => Ok(Reject::OutOfRange),
+ 5 => Ok(Reject::Incomplete),
+ 6 => Ok(Reject::Storage),
+ 7 => Ok(Reject::Malformed),
+ _ => Err(WireError::InvalidField),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn round_trip_request(request: Request<'_>) {
+ let mut buf = [0u8; MAX_REQUEST_SIZE];
+ let len = request.encode(&mut buf).expect("encode failed");
+ assert_eq!(Request::decode(&buf[..len]), Ok(request));
+ }
+
+ #[test]
+ fn every_request_round_trips() {
+ round_trip_request(Request::Offer {
+ target: TargetId(3),
+ total: 12 * 1024 * 1024,
+ });
+ round_trip_request(Request::Write {
+ offset: u64::from(u32::MAX) + 1,
+ bytes: &[0xa5; MAX_CHUNK],
+ });
+ round_trip_request(Request::Write {
+ offset: 0,
+ bytes: &[],
+ });
+ round_trip_request(Request::Complete);
+ round_trip_request(Request::Abort);
+ round_trip_request(Request::Poll);
+ }
+
+ #[test]
+ fn a_request_header_is_sixteen_bytes() {
+ // Part of the protocol, not an artifact of the field list: a handler
+ // sizes its read buffer from it.
+ assert_eq!(RequestHeader::SIZE, 16);
+ assert_eq!(MAX_REQUEST_SIZE, 16 + MAX_CHUNK);
+ }
+
+ #[test]
+ fn a_chunk_over_the_bound_is_refused_by_both_sides() {
+ let oversized = [0u8; MAX_CHUNK + 1];
+ let mut buf = [0u8; MAX_REQUEST_SIZE + 1];
+
+ assert_eq!(
+ Request::Write {
+ offset: 0,
+ bytes: &oversized,
+ }
+ .encode(&mut buf),
+ Err(WireError::InvalidField)
+ );
+
+ // Hand-built by a source ignoring the bound: the handler must not
+ // read past its own buffer on the strength of a declared length.
+ let header = RequestHeader::new(UpdateOp::Write, 0, 0, (MAX_CHUNK + 1) as u16);
+ buf[..RequestHeader::SIZE].copy_from_slice(header.as_bytes());
+ assert_eq!(
+ Request::decode(&buf[..RequestHeader::SIZE + MAX_CHUNK + 1]),
+ Err(WireError::InvalidField)
+ );
+ }
+
+ #[test]
+ fn a_write_shorter_than_its_declared_chunk_is_truncated() {
+ let mut buf = [0u8; MAX_REQUEST_SIZE];
+ let len = Request::Write {
+ offset: 0,
+ bytes: &[1, 2, 3, 4],
+ }
+ .encode(&mut buf)
+ .expect("encode failed");
+
+ assert_eq!(Request::decode(&buf[..len - 1]), Err(WireError::Truncated));
+ }
+
+ #[test]
+ fn a_short_header_is_truncated_not_a_panic() {
+ for len in 0..RequestHeader::SIZE {
+ assert_eq!(
+ Request::decode(&[0u8; 32][..len]),
+ Err(WireError::Truncated)
+ );
+ }
+ }
+
+ #[test]
+ fn an_unknown_opcode_is_reported_with_its_value() {
+ let mut buf = [0u8; RequestHeader::SIZE];
+ buf[0] = 9;
+
+ assert_eq!(Request::decode(&buf), Err(WireError::InvalidOpcode(9)));
+ }
+
+ #[test]
+ fn fields_an_op_does_not_define_must_be_zero() {
+ // Reserved, so they stay free for a later meaning.
+ let mut buf = [0u8; RequestHeader::SIZE];
+ buf[0] = UpdateOp::Poll as u8;
+ buf[2] = 1;
+ assert_eq!(Request::decode(&buf), Err(WireError::InvalidField));
+
+ // A chunk length on an op that carries no chunk.
+ let header = RequestHeader::new(UpdateOp::Complete, 0, 0, 4);
+ assert_eq!(
+ Request::decode(header.as_bytes()),
+ Err(WireError::InvalidField)
+ );
+
+ // An arg on an op that has no use for one.
+ let header = RequestHeader::new(UpdateOp::Poll, 0, 64, 0);
+ assert_eq!(
+ Request::decode(header.as_bytes()),
+ Err(WireError::InvalidField)
+ );
+ }
+
+ fn round_trip_response(response: Response) {
+ let mut buf = [0u8; MAX_RESPONSE_SIZE];
+ let len = response.encode(&mut buf).expect("encode failed");
+ assert_eq!(len, MAX_RESPONSE_SIZE);
+ assert_eq!(Response::decode(&buf[..len]), Ok(response));
+ }
+
+ #[test]
+ fn every_phase_round_trips() {
+ let progress = Progress {
+ written: 4096,
+ total: 12 * 1024 * 1024,
+ };
+ for status in [
+ IntakeStatus::Idle,
+ IntakeStatus::Receiving(progress),
+ IntakeStatus::Authenticating,
+ IntakeStatus::Staging(progress),
+ IntakeStatus::Activated,
+ IntakeStatus::Failed(FailureCause::Authentication),
+ IntakeStatus::Failed(FailureCause::Deferred),
+ IntakeStatus::Failed(FailureCause::Superseded),
+ IntakeStatus::Failed(FailureCause::Device),
+ ] {
+ round_trip_response(Response {
+ outcome: Ok(()),
+ status,
+ });
+ }
+ }
+
+ #[test]
+ fn every_reject_round_trips_and_keeps_the_phase() {
+ // The phase rides along with a refusal, so a source that only
+ // writes still learns that its job died.
+ for reject in [
+ Reject::UnknownTarget,
+ Reject::Busy,
+ Reject::NoJob,
+ Reject::OutOfRange,
+ Reject::Incomplete,
+ Reject::Storage,
+ Reject::Malformed,
+ ] {
+ round_trip_response(Response {
+ outcome: Err(reject),
+ status: IntakeStatus::Failed(FailureCause::Device),
+ });
+ }
+ }
+
+ #[test]
+ fn progress_is_zero_outside_the_phases_that_carry_it() {
+ let mut buf = [0u8; MAX_RESPONSE_SIZE];
+ Response {
+ outcome: Ok(()),
+ status: IntakeStatus::Activated,
+ }
+ .encode(&mut buf)
+ .expect("encode failed");
+
+ assert_eq!(buf[ResponseHeader::SIZE - 16..], [0u8; 16]);
+ }
+
+ #[test]
+ fn an_undefined_phase_or_cause_does_not_decode() {
+ let mut buf = [0u8; MAX_RESPONSE_SIZE];
+ buf[1] = 9;
+ assert_eq!(Response::decode(&buf), Err(WireError::InvalidField));
+
+ // Failed with no cause: the two only mean something together.
+ buf[1] = PHASE_FAILED;
+ buf[2] = 0;
+ assert_eq!(Response::decode(&buf), Err(WireError::InvalidField));
+ }
+
+ #[test]
+ fn a_short_response_is_truncated_not_a_panic() {
+ for len in 0..ResponseHeader::SIZE {
+ assert_eq!(
+ Response::decode(&[0u8; 32][..len]),
+ Err(WireError::Truncated)
+ );
+ }
+ }
+}