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) + ); + } + } +}