Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/draupnir

src/redfish_server.rs

//! **Draupnir's own BMC** — a pure-Rust **Redfish server** that fronts KVM.
//!
//! `src/redfish.rs` is the Redfish **client**: it drives a real iDRAC / iLO /
//! OpenBMC. This is the other end — a Redfish **service** that answers the same
//! endpoints and, instead of a baseboard controller wired to a motherboard, drives
//! a QEMU/KVM guest through draupnir's own [`Boot`] / [`Lifecycle`] seam.
//!
//! # Why it exists
//!
//! Two reasons, and the second is the durable one.
//!
//! 1. A Redfish burn needs a **BMC**: the always-on management controller a real
//!    server has and a developer box does not. Without one the `iso-metal` proof is
//!    a human with a USB stick, which is not a matrix row. This gives the wire half
//!    of that proof a service to run against.
//! 2. A Rust Redfish server fronting KVM turns **any Linux box into a
//!    Redfish-controllable machine**. That is squarely this crate's charter (kvm and
//!    container, low level), and it is useful far outside a test harness: a lab, a
//!    CI fleet, a nested-virt pool can all be addressed with the same out-of-band
//!    vocabulary as the metal they stand in for.
//!
//! # What it does NOT prove — read this before believing a green
//!
//! **An emulator proves the wire, never the hardware.** draupnir's client talking to
//! draupnir's server is a mirror: both ends can be wrong in the same way and every
//! round trip still passes. A loopback green on its own is evidence of
//! self-consistency and of nothing else.
//!
//! Two things are done about that, and neither is optional:
//!
//! * **The shapes are anchored outside this repo.** `tests/redfish_server_conformance.rs`
//!   checks every response against **DMTF's own published JSON Schema, message
//!   registry and mockup** (vendored under `tests/fixtures/dmtf/`, provenance and
//!   hashes in `PROVENANCE.md`). Required properties, `@odata.type` versions,
//!   `@Redfish.AllowableValues` subsets and the `error` payload are all read out of
//!   DMTF's files, not out of ours.
//! * **The result is recorded as `iso-redfish-sim`, never `iso-metal`.** Recording an
//!   emulator's green in the cell a real-hardware green would occupy is exactly the
//!   false green the form split exists to prevent. `iso-metal` stays Absent.
//!
//! # The translation
//!
//! Every Redfish verb lands on vocabulary this crate already had — nothing new was
//! invented for the server side:
//!
//! | Redfish | draupnir |
//! |---|---|
//! | `VirtualMedia.InsertMedia` | [`BootSpec::medium`] |
//! | `Boot.BootSourceOverrideTarget` | [`BootOrder`] (via [`BootOrder::from_redfish_target`]) |
//! | `ComputerSystem.Reset` | [`Boot::boot`] / [`Lifecycle::power_off`] |
//! | `ComputerSystem.PowerState` | [`Lifecycle::status`] |
//!
//! and the URLs, property names and enum tokens come from [`crate::redfish::wire`],
//! the **one** module both ends read. The client composes `host + path()`; the
//! server routes on `path()`. They agree by construction.
//!
//! # Security posture
//!
//! draupnir's client **pins** the BMC certificate, so this server needs a real TLS
//! identity to be pinned. It mints a genuine self-signed X.509 with SANs per run
//! (`rcgen` over `ring` — the same crate, features and call shape as Skidbladnir's
//! `pki::issue_leaf`) and holds the key **in memory only**: nothing is written to
//! disk, so there is no `.key`/`.pem`/`.der` that could be committed. The cert PEM is
//! handed out through [`RedfishKvmServer::cert_pem`] for the client to pin.
//!
//! Everything except the protocol-version probe and the service root requires HTTP
//! Basic auth, which is what DSP0266 permits an unauthenticated client to reach.
//!
//! # Example
//!
//! ```no_run
//! use draupnir::redfish_server::{NodeConfig, RedfishKvmServer};
//! use draupnir::redfish::RedfishBoot;
//! use draupnir::{Boot, BootSpec};
//!
//! // A BMC in front of this box's KVM.
//! let server = RedfishKvmServer::start(
//!     NodeConfig::new("System.Embedded.1")
//!         .credentials("admin", "secret")
//!         .local_disk("/var/lib/node.qcow2"),
//! )?;
//!
//! // ...driven by draupnir's ordinary Redfish CLIENT, cert pinned.
//! let backend = RedfishBoot::new()
//!     .with_password("secret")
//!     .pin_cert_pem(server.cert_pem().as_bytes().to_vec());
//! let spec = BootSpec::iso_boot("burn", "/images/appliance.iso")
//!     .on_metal(server.bmc_endpoint());
//! let machine = backend.boot(&spec)?;
//! # Ok::<(), draupnir::Error>(())
//! ```

use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use serde_json::{json, Value};

use crate::kvm::KvmBoot;
use crate::redfish::wire::{self, prop};
use crate::{
    BmcEndpoint, Boot, BootOrder, BootSpec, BootTarget, Error, Lifecycle, Machine, PowerState,
    Result, Seen,
};

// ===========================================================================
// DMTF message registry — the error vocabulary, transcribed and then CHECKED
// ===========================================================================

/// The DMTF **Base** message registry this service reports `MessageId`s from.
///
/// Every `MessageId` it emits is `{BASE_REGISTRY}.{Id}`, and every message body is
/// the registry's own template with `MessageArgs` substituted. The conformance test
/// loads `tests/fixtures/dmtf/registries/Base.1.19.0.json` and asserts that each
/// [`Message`] below matches the registry's `Message`, `MessageSeverity`,
/// `NumberOfArgs` and `Resolution` **exactly** — so a hand-typed error string that
/// merely *sounds* like Redfish cannot survive here.
pub const BASE_REGISTRY: &str = "Base.1.19.0";

/// One entry of the DMTF Base registry, as this service reports it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Message {
    /// The registry entry's `Id` (the part after the registry name in a `MessageId`).
    pub id: &'static str,
    /// The registry's `Message` template, with `%1`..`%N` placeholders.
    pub template: &'static str,
    /// The registry's `MessageSeverity`.
    pub severity: &'static str,
    /// The registry's `Resolution`.
    pub resolution: &'static str,
    /// The registry's `NumberOfArgs`.
    pub nargs: usize,
    /// The HTTP status this service answers with when it reports this message.
    pub status: u16,
}

impl Message {
    /// The full `MessageId`, e.g. `Base.1.19.0.ActionParameterValueNotInList`.
    pub fn message_id(&self) -> String {
        format!("{BASE_REGISTRY}.{}", self.id)
    }

    /// Render the registry template with `args` substituted for `%1`..`%N`.
    ///
    /// A wrong arity is a **programming** error in this crate, not a client error, so
    /// it is caught by a debug assertion and then rendered honestly (the unfilled
    /// placeholder stays visible) rather than papered over.
    pub fn render(&self, args: &[&str]) -> String {
        debug_assert_eq!(
            args.len(),
            self.nargs,
            "{} takes {} args, got {}",
            self.id,
            self.nargs,
            args.len()
        );
        let mut out = self.template.to_string();
        // Descending index, so `%10` (were the registry ever to grow one) is not
        // eaten by `%1`.
        for (i, a) in args.iter().enumerate().rev() {
            out = out.replace(&format!("%{}", i + 1), a);
        }
        out
    }
}

/// `The value '%1' for the parameter %2 in the action %3 is not in the list of
/// acceptable values.` — a bad `ResetType`.
pub const ACTION_PARAMETER_VALUE_NOT_IN_LIST: Message = Message {
    id: "ActionParameterValueNotInList",
    template: "The value '%1' for the parameter %2 in the action %3 is not in the list of acceptable values.",
    severity: "Warning",
    resolution: "Choose a value from the enumeration list that the implementation can support and resubmit the request if the operation failed.",
    nargs: 3,
    status: 400,
};

/// `The action %1 requires the parameter %2 to be present in the request body.`
pub const ACTION_PARAMETER_MISSING: Message = Message {
    id: "ActionParameterMissing",
    template: "The action %1 requires the parameter %2 to be present in the request body.",
    severity: "Critical",
    resolution: "Supply the action with the required parameter in the request body when the request is resubmitted.",
    nargs: 2,
    status: 400,
};

/// `The value '%1' for the property %2 is not in the list of acceptable values.` —
/// a `BootSourceOverrideTarget` this node cannot honour.
pub const PROPERTY_VALUE_NOT_IN_LIST: Message = Message {
    id: "PropertyValueNotInList",
    template: "The value '%1' for the property %2 is not in the list of acceptable values.",
    severity: "Warning",
    resolution: "Choose a value from the enumeration list that the implementation can support and resubmit the request if the operation failed.",
    nargs: 2,
    status: 400,
};

/// `The resource at the URI '%1' was not found.` — an `InsertMedia` pointing at an
/// image that is not there. THE red control: a media URI nobody built must be
/// refused **by name**, not mounted as an empty tray.
pub const RESOURCE_MISSING_AT_URI: Message = Message {
    id: "ResourceMissingAtURI",
    template: "The resource at the URI '%1' was not found.",
    severity: "Critical",
    resolution: "Place a valid resource at the URI or correct the URI and resubmit the request.",
    nargs: 1,
    status: 400,
};

/// `The requested resource of type %1 named '%2' was not found.`
pub const RESOURCE_NOT_FOUND: Message = Message {
    id: "ResourceNotFound",
    template: "The requested resource of type %1 named '%2' was not found.",
    severity: "Critical",
    resolution: "Provide a valid resource identifier and resubmit the request.",
    nargs: 2,
    status: 404,
};

/// `There is no valid session established with the implementation.` — no (or bad)
/// credentials.
pub const NO_VALID_SESSION: Message = Message {
    id: "NoValidSession",
    template: "There is no valid session established with the implementation.",
    severity: "Critical",
    resolution: "Establish a session before attempting any operations.",
    nargs: 0,
    status: 401,
};

/// `The action %1 is not supported by the resource.`
pub const ACTION_NOT_SUPPORTED: Message = Message {
    id: "ActionNotSupported",
    template: "The action %1 is not supported by the resource.",
    severity: "Critical",
    resolution: "Check the Actions property in the resource for the supported actions.",
    nargs: 1,
    status: 400,
};

/// `The request body submitted was malformed JSON and could not be parsed by the
/// receiving service.`
pub const MALFORMED_JSON: Message = Message {
    id: "MalformedJSON",
    template: "The request body submitted was malformed JSON and could not be parsed by the receiving service.",
    severity: "Critical",
    resolution: "Ensure that the request body is valid JSON and resubmit the request.",
    nargs: 0,
    status: 400,
};

/// `A general error has occurred.  See Resolution for information on how to resolve
/// the error, or @Message.ExtendedInfo if Resolution is not provided.` — the
/// catch-all for a backend that refused (QEMU would not start, no medium inserted,
/// no local disk configured). The **detail always names what happened**, in the
/// `Resolution`-shaped second sentence of the extended info.
pub const GENERAL_ERROR: Message = Message {
    id: "GeneralError",
    template: "A general error has occurred.  See Resolution for information on how to resolve the error, or @Message.ExtendedInfo if Resolution is not provided.",
    severity: "Critical",
    resolution: "None.",
    nargs: 0,
    status: 500,
};

/// Every message this service can emit — the list the conformance test walks when it
/// checks each one against DMTF's registry. A new message added below and forgotten
/// here is a message nothing checks, so this array is the gate.
pub const ALL_MESSAGES: &[Message] = &[
    ACTION_PARAMETER_VALUE_NOT_IN_LIST,
    ACTION_PARAMETER_MISSING,
    PROPERTY_VALUE_NOT_IN_LIST,
    RESOURCE_MISSING_AT_URI,
    RESOURCE_NOT_FOUND,
    NO_VALID_SESSION,
    ACTION_NOT_SUPPORTED,
    MALFORMED_JSON,
    GENERAL_ERROR,
];

/// The `@odata.type` values this service reports, one per resource. Pinned as
/// constants because the conformance test resolves each against the matching DMTF
/// schema file and fails if the named version does not define the named type.
mod odata_type {
    /// `ServiceRoot`.
    pub const SERVICE_ROOT: &str = "#ServiceRoot.v1_16_1.ServiceRoot";
    /// `ComputerSystemCollection` (collections are unversioned).
    pub const SYSTEM_COLLECTION: &str = "#ComputerSystemCollection.ComputerSystemCollection";
    /// `ComputerSystem`.
    pub const SYSTEM: &str = "#ComputerSystem.v1_22_0.ComputerSystem";
    /// `VirtualMediaCollection` (unversioned).
    pub const VIRTUAL_MEDIA_COLLECTION: &str = "#VirtualMediaCollection.VirtualMediaCollection";
    /// `VirtualMedia`.
    pub const VIRTUAL_MEDIA: &str = "#VirtualMedia.v1_6_3.VirtualMedia";
    /// `SessionCollection` (unversioned).
    pub const SESSION_COLLECTION: &str = "#SessionCollection.SessionCollection";
}

/// The Redfish protocol version this service reports as `ServiceRoot.RedfishVersion`.
const REDFISH_VERSION: &str = "1.15.0";

// ===========================================================================
// Configuration
// ===========================================================================

/// **The node this BMC fronts** — what the emulated `ComputerSystem` looks like and
/// which local resources it actually drives.
#[derive(Debug, Clone)]
pub struct NodeConfig {
    /// The Redfish `ComputerSystem` id — the `{system_id}` in every URL, and what a
    /// [`BmcEndpoint`] carries. Choose the same spelling a real BMC of the fleet
    /// would use (`System.Embedded.1` for iDRAC, `1` for iLO) so a spec written
    /// against the emulator is byte-identical against the metal.
    pub system_id: String,
    /// The `VirtualMedia` slot id the ISO is inserted into. Defaults to
    /// [`crate::redfish::DEFAULT_MEDIA_ID`] (`"CD"`) — the slot draupnir's own client
    /// targets when nothing else is said. (DMTF's mockup happens to call its slot
    /// `CD1`, and iLO calls it `1`: the id is an instance detail, never spec-fixed.)
    pub media_slot: String,
    /// The Redfish account username.
    pub username: String,
    /// The Redfish account password.
    pub password: String,
    /// The address to bind. `127.0.0.1:0` (the default) takes an ephemeral port;
    /// read the real one back with [`RedfishKvmServer::addr`].
    pub bind: SocketAddr,
    /// **The node's local disk** — what a `BootSourceOverrideTarget: "Hdd"` actually
    /// boots. `None` means this node has no disk, and an `Hdd` override is then
    /// refused by name rather than silently falling through to the medium (which is
    /// precisely how a boot override gets "accepted" without ever being applied).
    pub local_disk: Option<String>,
    /// Guest RAM in MiB for the VM this BMC boots.
    pub mem_mb: u32,
    /// vCPU count for the VM this BMC boots.
    pub cores: u32,
    /// `ComputerSystem.Name`.
    pub system_name: String,
    /// `ComputerSystem.Manufacturer`.
    pub manufacturer: String,
    /// `ComputerSystem.Model`.
    pub model: String,
    /// `ComputerSystem.UUID` / `ServiceRoot.UUID`.
    pub uuid: String,
    /// Extra TLS SANs beyond `localhost` / `127.0.0.1` — set when the BMC is reached
    /// by a name other than loopback.
    pub extra_sans: Vec<String>,
}

impl NodeConfig {
    /// A node with `system_id`, bound to an ephemeral loopback port, `admin` with no
    /// password, no local disk, 1024 MiB / 2 cores.
    pub fn new(system_id: impl Into<String>) -> Self {
        Self {
            system_id: system_id.into(),
            media_slot: crate::redfish::DEFAULT_MEDIA_ID.to_string(),
            username: "admin".into(),
            password: String::new(),
            bind: SocketAddr::from(([127, 0, 0, 1], 0)),
            local_disk: None,
            mem_mb: 1024,
            cores: 2,
            system_name: "draupnir-node".into(),
            manufacturer: "nordisk".into(),
            model: "draupnir Redfish/KVM".into(),
            uuid: "1f0d3a26-4c5b-4e77-9a2c-6b1e0d7a55f1".into(),
            extra_sans: Vec::new(),
        }
    }

    /// Set the Redfish account (builder style).
    pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
        self.username = username.into();
        self.password = password.into();
        self
    }

    /// Set the address to bind (builder style).
    pub fn bind(mut self, bind: SocketAddr) -> Self {
        self.bind = bind;
        self
    }

    /// Set the `VirtualMedia` slot id (builder style).
    pub fn media_slot(mut self, slot: impl Into<String>) -> Self {
        self.media_slot = slot.into();
        self
    }

    /// Give the node a local disk — what an `Hdd` boot override boots (builder style).
    pub fn local_disk(mut self, disk: impl Into<String>) -> Self {
        self.local_disk = Some(disk.into());
        self
    }

    /// Size the VM this BMC boots (builder style).
    pub fn sized(mut self, mem_mb: u32, cores: u32) -> Self {
        self.mem_mb = mem_mb;
        self.cores = cores;
        self
    }

    /// Add TLS SANs (builder style).
    pub fn extra_sans<I, S>(mut self, sans: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.extra_sans.extend(sans.into_iter().map(Into::into));
        self
    }
}

// ===========================================================================
// The node's state
// ===========================================================================

/// The mutable state of the emulated node — everything the six endpoints read and
/// write. One `Mutex` guards it all: this is a control plane answering a handful of
/// requests, not a data path, and a single lock is the shape that cannot deadlock.
#[derive(Debug, Default)]
struct NodeState {
    /// The media URI exactly as the client named it (`VirtualMedia.Image`).
    image_uri: Option<String>,
    /// The local file that URI resolved to — what a boot actually attaches.
    image_path: Option<String>,
    /// `VirtualMedia.Inserted`.
    inserted: bool,
    /// `VirtualMedia.WriteProtected`.
    write_protected: bool,
    /// `Boot.BootSourceOverrideEnabled`.
    override_enabled: String,
    /// `Boot.BootSourceOverrideTarget`, once mapped.
    override_target: Option<BootTarget>,
    /// The machine the last successful power-on produced.
    machine: Option<Machine>,
    /// The [`BootSpec`] the last power-on actually handed the backend — the applied
    /// output, readable by a caller that wants to prove *what* was booted rather than
    /// that something was.
    last_spec: Option<BootSpec>,
    /// The last `ResetType` accepted.
    last_reset: Option<String>,
}

impl NodeState {
    fn fresh() -> Self {
        Self {
            override_enabled: wire::OVERRIDE_DISABLED.to_string(),
            ..Default::default()
        }
    }
}

// ===========================================================================
// The backend seam
// ===========================================================================

/// What this BMC can drive: anything that can be [`Boot`]ed and has a
/// [`Lifecycle`].
///
/// [`KvmBoot`] is the real one. The seam is a trait so the conformance suite can
/// front a **recording** backend that launches nothing — the wire shapes must be
/// provable on a box with no `/dev/kvm`, and a conformance test that needed a real
/// VM would be a conformance test nobody ran.
pub trait NodeBackend: Boot + Lifecycle + Send + Sync {}
impl<T: Boot + Lifecycle + Send + Sync> NodeBackend for T {}

// ===========================================================================
// The server
// ===========================================================================

/// A running Redfish service in front of a KVM host.
///
/// Started with [`start`](RedfishKvmServer::start) (real KVM) or
/// [`start_with`](RedfishKvmServer::start_with) (any [`NodeBackend`]). It listens on
/// its own background thread — a [`gatling::background::Job`], the constellation's
/// sanctioned home for a raw thread — until [`shutdown`](RedfishKvmServer::shutdown)
/// or drop.
pub struct RedfishKvmServer {
    cfg: NodeConfig,
    addr: SocketAddr,
    cert_pem: String,
    state: Arc<Mutex<NodeState>>,
    stop: Arc<AtomicBool>,
    /// The accept loop. `Option` so `shutdown` can take and join it.
    job: Option<gatling::background::Job<()>>,
    /// Kept typed (not just as a `NodeBackend`) so the serial console of the guest
    /// this BMC booted is readable — the applied output a caller needs to prove the
    /// override was honoured, rather than merely acknowledged.
    kvm: Option<Arc<KvmBoot>>,
}

impl std::fmt::Debug for RedfishKvmServer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RedfishKvmServer")
            .field("addr", &self.addr)
            .field("system_id", &self.cfg.system_id)
            .field("media_slot", &self.cfg.media_slot)
            // Never the password, and never the private key (which is not held here
            // at all — only the public cert PEM).
            .field("cert_pem", &format_args!("<{} bytes>", self.cert_pem.len()))
            .finish_non_exhaustive()
    }
}

impl RedfishKvmServer {
    /// Start a BMC in front of **this box's KVM** ([`KvmBoot`]).
    pub fn start(cfg: NodeConfig) -> Result<Self> {
        let kvm = Arc::new(KvmBoot::new());
        let backend: Arc<dyn NodeBackend> = kvm.clone();
        let mut me = Self::start_with(cfg, backend)?;
        me.kvm = Some(kvm);
        Ok(me)
    }

    /// Start a BMC in front of an arbitrary [`NodeBackend`] — the seam the
    /// conformance suite uses to prove the wire with no VM anywhere.
    pub fn start_with(cfg: NodeConfig, backend: Arc<dyn NodeBackend>) -> Result<Self> {
        let (tls_config, cert_pem) = tls_identity(&cfg)?;
        let listener = TcpListener::bind(cfg.bind)
            .map_err(|e| Error::Backend(format!("redfish server bind {}: {e}", cfg.bind)))?;
        let addr = listener
            .local_addr()
            .map_err(|e| Error::Backend(format!("redfish server local_addr: {e}")))?;
        // Non-blocking accept + a short poll is what lets `shutdown` be prompt
        // without a self-connect trick; the accepted socket is put back into
        // blocking mode before the TLS handshake, which needs it.
        listener
            .set_nonblocking(true)
            .map_err(|e| Error::Backend(format!("redfish server set_nonblocking: {e}")))?;

        let state = Arc::new(Mutex::new(NodeState::fresh()));
        let stop = Arc::new(AtomicBool::new(false));

        let service = Service {
            routes: Routes::for_node(&cfg),
            cfg: cfg.clone(),
            state: state.clone(),
            backend,
        };
        let loop_stop = stop.clone();
        let job = gatling::background::Job::spawn(move || {
            accept_loop(listener, tls_config, service, loop_stop)
        });

        crate::functional_status(
            "draupnir/redfish-server",
            "start",
            true,
            &format!(
                "BMC for system `{}` listening on https://{addr} (slot `{}`)",
                cfg.system_id, cfg.media_slot
            ),
        );

        Ok(Self {
            cfg,
            addr,
            cert_pem,
            state,
            stop,
            job: Some(job),
            kvm: None,
        })
    }

    /// The address it is listening on (the real port when `bind` asked for `:0`).
    pub fn addr(&self) -> SocketAddr {
        self.addr
    }

    /// The service base URL, e.g. `https://127.0.0.1:41234`.
    pub fn base_url(&self) -> String {
        format!("https://{}", self.addr)
    }

    /// The **PEM certificate to pin** — feed it to
    /// [`RedfishBoot::pin_cert_pem`](crate::redfish::RedfishBoot::pin_cert_pem) so the
    /// client keeps full TLS verification on with this cert as its sole trusted root.
    pub fn cert_pem(&self) -> &str {
        &self.cert_pem
    }

    /// A [`BmcEndpoint`] addressing this server — what a spec's
    /// [`on_metal`](BootSpec::on_metal) takes.
    pub fn bmc_endpoint(&self) -> BmcEndpoint {
        BmcEndpoint {
            host: self.base_url(),
            username: self.cfg.username.clone(),
            system_id: self.cfg.system_id.clone(),
        }
    }

    /// The machine the last accepted power-on produced, if any.
    pub fn machine(&self) -> Option<Machine> {
        self.state.lock().unwrap().machine.clone()
    }

    /// **The [`BootSpec`] the BMC actually handed its backend** on the last power-on.
    ///
    /// This is the applied output at the spec level: it says *what* the node was told
    /// to boot, so a test can prove a boot override was honoured rather than merely
    /// acknowledged with a `204`.
    pub fn last_boot_spec(&self) -> Option<BootSpec> {
        self.state.lock().unwrap().last_spec.clone()
    }

    /// The media URI currently inserted (`VirtualMedia.Image`), if any.
    pub fn inserted_image(&self) -> Option<String> {
        let s = self.state.lock().unwrap();
        s.inserted.then(|| s.image_uri.clone()).flatten()
    }

    /// **The serial console of the guest this BMC booted** — the applied output at
    /// the machine level. `None` unless the server was started with
    /// [`start`](RedfishKvmServer::start) (a real [`KvmBoot`]) and a VM is live.
    ///
    /// Redfish itself has no "give me the console" GET, so this is deliberately a
    /// *host-side* accessor rather than an invented endpoint: an emulator that grew a
    /// non-standard resource would be a worse emulator.
    pub fn serial_log(&self) -> Option<String> {
        let kvm = self.kvm.as_ref()?;
        let machine = self.machine()?;
        kvm.serial_log(&machine)
    }

    /// Wait for a named line on the booted guest's console, or a deadline — the same
    /// [`Seen`] observation shape [`KvmBoot::await_serial_marker`] returns, because it
    /// *is* that call. `Err` when this server never booted a KVM guest.
    pub fn await_serial_marker(
        &self,
        marker: &str,
        budget: Duration,
        poll: Duration,
    ) -> Result<Seen> {
        let kvm = self.kvm.as_ref().ok_or_else(|| {
            Error::Backend(
                "this Redfish server does not front a KVM backend (started with start_with)".into(),
            )
        })?;
        let machine = self
            .machine()
            .ok_or_else(|| Error::Backend("no machine has been powered on yet".into()))?;
        kvm.await_serial_marker(&machine, marker, budget, poll)
    }

    /// Stop the listener and join its thread.
    pub fn shutdown(&mut self) {
        self.stop.store(true, Ordering::SeqCst);
        if let Some(job) = self.job.take() {
            let _ = job.join();
        }
    }
}

impl Drop for RedfishKvmServer {
    fn drop(&mut self) {
        self.shutdown();
        // A BMC that "powered off" by exiting would leave a QEMU process on the box.
        if let (Some(kvm), Some(machine)) = (self.kvm.as_ref(), self.machine()) {
            let _ = kvm.power_off(&machine);
        }
    }
}

// ===========================================================================
// TLS identity
// ===========================================================================

/// Mint the server's TLS identity and build a `rustls` config from it.
///
/// A genuine self-signed X.509 with `localhost`, `127.0.0.1`, the bound IP and any
/// [`NodeConfig::extra_sans`] as SANs. The private key never leaves this function's
/// return value — it goes straight into the `rustls` config and is never serialized
/// to disk, so no key material can be left behind or committed.
#[allow(clippy::type_complexity)]
fn tls_identity(cfg: &NodeConfig) -> Result<(Arc<rustls::ServerConfig>, String)> {
    let mut sans: Vec<String> = vec![
        "localhost".into(),
        "127.0.0.1".into(),
        "::1".into(),
        cfg.bind.ip().to_string(),
    ];
    sans.extend(cfg.extra_sans.iter().cloned());
    sans.sort();
    sans.dedup();

    let mut params = rcgen::CertificateParams::new(sans)
        .map_err(|e| Error::Backend(format!("redfish server cert params: {e}")))?;
    params.distinguished_name = {
        let mut dn = rcgen::DistinguishedName::new();
        dn.push(
            rcgen::DnType::CommonName,
            format!("draupnir BMC {}", cfg.system_id),
        );
        dn.push(rcgen::DnType::OrganizationName, "nordisk");
        dn
    };
    let key = rcgen::KeyPair::generate()
        .map_err(|e| Error::Backend(format!("redfish server keypair: {e}")))?;
    let cert = params
        .self_signed(&key)
        .map_err(|e| Error::Backend(format!("redfish server self-sign: {e}")))?;
    let cert_pem = cert.pem();

    let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
    let key_der = rustls::pki_types::PrivateKeyDer::try_from(key.serialize_der())
        .map_err(|e| Error::Backend(format!("redfish server key DER: {e}")))?;

    let provider = Arc::new(rustls::crypto::ring::default_provider());
    let tls = rustls::ServerConfig::builder_with_provider(provider)
        .with_safe_default_protocol_versions()
        .map_err(|e| Error::Backend(format!("redfish server tls versions: {e}")))?
        .with_no_client_auth()
        .with_single_cert(vec![cert_der], key_der)
        .map_err(|e| Error::Backend(format!("redfish server tls cert: {e}")))?;

    Ok((Arc::new(tls), cert_pem))
}

// ===========================================================================
// The accept loop
// ===========================================================================

/// How long a single connection may take to deliver its request / read its response.
/// A BMC exchange is a few hundred bytes; a peer that stalls longer than this is
/// wedged, and holding the (deliberately serial) control plane for it would be worse
/// than dropping it.
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);

/// The idle poll between `accept` attempts — what makes shutdown prompt.
const ACCEPT_POLL: Duration = Duration::from_millis(5);

fn accept_loop(
    listener: TcpListener,
    tls: Arc<rustls::ServerConfig>,
    service: Service,
    stop: Arc<AtomicBool>,
) {
    while !stop.load(Ordering::SeqCst) {
        match listener.accept() {
            Ok((sock, _peer)) => {
                if sock.set_nonblocking(false).is_err() {
                    continue;
                }
                let _ = sock.set_read_timeout(Some(CONNECTION_TIMEOUT));
                let _ = sock.set_write_timeout(Some(CONNECTION_TIMEOUT));
                serve_connection(sock, &tls, &service);
            }
            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                std::thread::sleep(ACCEPT_POLL);
            }
            Err(_) => break,
        }
    }
}

/// Serve **one** request on one TLS connection, then close it gracefully.
///
/// # Why one request per connection, and not keep-alive
///
/// This was keep-alive first, and it produced a real, reproducible flake: measured on
/// this box 2026-08-15, **3 failures in 28** full runs of `cargo test --features
/// redfish-server,backend-redfish --test redfish_server_conformance`, each an
/// `ECONNRESET` raised by the client. Instrumenting the accept loop showed the classic
/// HTTP/1.1 **idle-close race**: the server had answered two requests on a pooled
/// connection, the client's pool retired it, and the client's next request went out on
/// the socket the server was already closing. After this change: **0 failures in 40**
/// consecutive runs.
///
/// The race is inherent to keep-alive and every client is supposed to retry through
/// it. A flaky proof is worse than a slow one, so this service does the thing a
/// server is always allowed to do: answer one request, say `Connection: close`, and
/// shut the connection down cleanly. Plenty of real BMCs behave exactly this way. The
/// cost is a TLS handshake per request — about a millisecond on loopback, for a
/// control plane that makes single-digit numbers of calls per boot.
///
/// It also removes a second defect keep-alive had: the accept loop is deliberately
/// **serial** (a BMC is not a data path), so a connection parked in `read` waiting for
/// a follow-up request that never came would hold the whole service — and would make
/// [`RedfishKvmServer::shutdown`] block for the full [`CONNECTION_TIMEOUT`].
fn serve_connection(sock: TcpStream, tls: &Arc<rustls::ServerConfig>, service: &Service) {
    let Ok(conn) = rustls::ServerConnection::new(tls.clone()) else {
        return;
    };
    let mut stream = rustls::StreamOwned::new(conn, sock);
    let mut buf: Vec<u8> = Vec::with_capacity(2048);
    match read_request(&mut stream, &mut buf) {
        // A clean close, or a peer that went away mid-request: nothing to answer.
        Ok(None) => {}
        Ok(Some(request)) => {
            let response = service
                .dispatch(&request)
                .with_header("Connection", "close");
            let _ = write_response(&mut stream, &response);
        }
        Err(resp) => {
            let _ = write_response(&mut stream, &resp.with_header("Connection", "close"));
        }
    }
    graceful_close(stream);
}

/// Close a TLS connection so the peer sees a clean shutdown rather than a reset.
///
/// Three steps, each load-bearing: send the TLS `close_notify` (so the client's TLS
/// layer sees an orderly end of stream, not a truncation), `shutdown(Write)` to send
/// the FIN, and then **drain** whatever the peer still had in flight. That last one is
/// the reset: the kernel emits RST instead of FIN when a socket is closed with unread
/// data in its receive queue, which is precisely how a well-behaved client ends up
/// reporting "connection reset by peer" against a server that did nothing wrong.
fn graceful_close(mut stream: rustls::StreamOwned<rustls::ServerConnection, TcpStream>) {
    stream.conn.send_close_notify();
    let _ = stream.flush();
    let _ = stream.sock.shutdown(std::net::Shutdown::Write);
    let _ = stream
        .sock
        .set_read_timeout(Some(Duration::from_millis(250)));
    let mut sink = [0u8; 1024];
    // Bounded: drain what is there, then stop. An endless drain would be a new way
    // for one peer to hold the serial accept loop.
    for _ in 0..16 {
        match stream.sock.read(&mut sink) {
            Ok(0) | Err(_) => break,
            Ok(_) => continue,
        }
    }
}

// ===========================================================================
// Minimal HTTP/1.1 — pure std, no framework
// ===========================================================================

/// The largest request head this service will read. A Redfish request head is a few
/// hundred bytes; anything past this is not a BMC client.
const MAX_HEAD: usize = 16 * 1024;
/// The largest request body. The biggest legitimate body here is an `InsertMedia`
/// with a long URI.
const MAX_BODY: usize = 1024 * 1024;

/// A parsed HTTP request.
struct Request {
    method: String,
    /// The percent-decoded path, without the query string.
    path: String,
    headers: BTreeMap<String, String>,
    body: Vec<u8>,
}

impl Request {
    fn header(&self, name: &str) -> Option<&str> {
        self.headers.get(&name.to_ascii_lowercase()).map(String::as_str)
    }
}

/// A response to write.
struct Response {
    status: u16,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

impl Response {
    fn new(status: u16) -> Self {
        Self {
            status,
            headers: Vec::new(),
            body: Vec::new(),
        }
    }

    fn with_header(mut self, name: &str, value: impl Into<String>) -> Self {
        self.headers.push((name.to_string(), value.into()));
        self
    }

    /// A JSON resource body.
    fn json(status: u16, value: &Value) -> Self {
        let mut r = Self::new(status);
        r.body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
        r.headers.push((
            "Content-Type".into(),
            "application/json;charset=utf-8".into(),
        ));
        r
    }

    /// `204 No Content` — what a successful Redfish action or PATCH returns.
    fn no_content() -> Self {
        Self::new(204)
    }

    /// A DMTF-shaped `error` payload (`redfish-error.v1_0_2`): `error.code`,
    /// `error.message`, and one `@Message.ExtendedInfo` entry carrying the registry
    /// `MessageId`, the rendered `Message`, its `MessageArgs`, `MessageSeverity` and
    /// `Resolution`.
    ///
    /// `detail`, when given, is appended to the extended info's `Resolution` — so a
    /// refusal always **names the thing it refused** even where DMTF's registry
    /// template has no argument for it.
    fn error(msg: Message, args: &[&str], detail: Option<&str>) -> Self {
        let rendered = msg.render(args);
        let resolution = match detail {
            Some(d) => format!("{} {d}", msg.resolution),
            None => msg.resolution.to_string(),
        };
        let mut info = json!({
            "@odata.type": "#Message.v1_1_2.Message",
            "MessageId": msg.message_id(),
            "Message": rendered,
            "MessageSeverity": msg.severity,
            "Resolution": resolution,
        });
        if !args.is_empty() {
            info["MessageArgs"] = json!(args);
        }
        Response::json(
            msg.status,
            &json!({
                "error": {
                    "code": msg.message_id(),
                    "message": rendered,
                    "@Message.ExtendedInfo": [info],
                }
            }),
        )
    }

    /// `405 Method Not Allowed` with the mandatory `Allow` header — DSP0266 requires
    /// the header, and a client that reads it can discover the right verb.
    fn method_not_allowed(allow: &str) -> Self {
        Response::error(
            ACTION_NOT_SUPPORTED,
            &["the requested method"],
            Some(&format!("Allowed methods: {allow}.")),
        )
        .with_status(405)
        .with_header("Allow", allow)
    }

    fn with_status(mut self, status: u16) -> Self {
        self.status = status;
        self
    }
}

fn reason(status: u16) -> &'static str {
    match status {
        200 => "OK",
        201 => "Created",
        204 => "No Content",
        400 => "Bad Request",
        401 => "Unauthorized",
        404 => "Not Found",
        405 => "Method Not Allowed",
        413 => "Payload Too Large",
        500 => "Internal Server Error",
        501 => "Not Implemented",
        _ => "Status",
    }
}

/// Read one request off `stream`, buffering leftovers for the next keep-alive round.
///
/// `Ok(None)` is a clean close. `Err(response)` is a request this service will not
/// even parse (too large, unreadable) and carries what to answer with.
fn read_request<S: Read>(stream: &mut S, buf: &mut Vec<u8>) -> std::result::Result<Option<Request>, Response> {
    // 1. Head — read until CRLFCRLF.
    let head_end = loop {
        if let Some(p) = find_subsequence(buf, b"\r\n\r\n") {
            break p;
        }
        if buf.len() > MAX_HEAD {
            return Err(Response::error(
                MALFORMED_JSON,
                &[],
                Some("The request head exceeded this service's limit."),
            )
            .with_status(413));
        }
        let mut chunk = [0u8; 1024];
        match stream.read(&mut chunk) {
            Ok(0) => return Ok(None),
            Ok(n) => buf.extend_from_slice(&chunk[..n]),
            Err(_) => return Ok(None),
        }
    };

    let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
    let mut lines = head.split("\r\n");
    let request_line = lines.next().unwrap_or_default();
    let mut parts = request_line.split_whitespace();
    let method = parts.next().unwrap_or_default().to_string();
    let raw_target = parts.next().unwrap_or_default();
    if method.is_empty() || raw_target.is_empty() {
        return Ok(None);
    }
    let path_part = raw_target.split('?').next().unwrap_or(raw_target);
    let path = percent_decode(path_part);

    let mut headers = BTreeMap::new();
    for line in lines {
        if let Some((k, v)) = line.split_once(':') {
            headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
        }
    }

    // 2. Body — Content-Length only. Redfish clients do not chunk their action
    //    bodies, and a service that guessed at a chunked body would be guessing.
    let len: usize = headers
        .get("content-length")
        .and_then(|v| v.parse().ok())
        .unwrap_or(0);
    if len > MAX_BODY {
        return Err(Response::error(
            MALFORMED_JSON,
            &[],
            Some("The request body exceeded this service's limit."),
        )
        .with_status(413));
    }
    let body_start = head_end + 4;
    while buf.len() < body_start + len {
        let mut chunk = [0u8; 4096];
        match stream.read(&mut chunk) {
            Ok(0) => return Ok(None),
            Ok(n) => buf.extend_from_slice(&chunk[..n]),
            Err(_) => return Ok(None),
        }
    }
    let body = buf[body_start..body_start + len].to_vec();
    // Keep whatever the peer pipelined behind this request.
    buf.drain(..body_start + len);

    Ok(Some(Request {
        method,
        path,
        headers,
        body,
    }))
}

fn write_response<S: Write>(stream: &mut S, resp: &Response) -> std::io::Result<()> {
    let mut out = Vec::with_capacity(256 + resp.body.len());
    out.extend_from_slice(
        format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status)).as_bytes(),
    );
    // Redfish requires OData-Version on every response.
    out.extend_from_slice(b"OData-Version: 4.0\r\n");
    out.extend_from_slice(b"Cache-Control: no-cache\r\n");
    for (k, v) in &resp.headers {
        out.extend_from_slice(format!("{k}: {v}\r\n").as_bytes());
    }
    out.extend_from_slice(format!("Content-Length: {}\r\n\r\n", resp.body.len()).as_bytes());
    out.extend_from_slice(&resp.body);
    stream.write_all(&out)?;
    stream.flush()
}

fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
    haystack
        .windows(needle.len())
        .position(|w| w == needle)
}

/// Percent-decode a URL path segment sequence. Invalid escapes are left verbatim
/// rather than dropped, so a path this service does not recognise fails as
/// "not found at *that* URI" and names the URI the client actually sent.
fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
            if let Some(b) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
                out.push(b);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

// ===========================================================================
// Routing
// ===========================================================================

/// The service's routing table — every path built from [`crate::redfish::wire`], the
/// same module the CLIENT builds its URLs from. This is the whole of the anti-mirror
/// discipline at the URL level: there is no second spelling to drift from.
#[derive(Debug, Clone)]
struct Routes {
    system: String,
    reset: String,
    vm_collection: String,
    vm: String,
    vm_insert: String,
    vm_eject: String,
}

impl Routes {
    fn for_node(cfg: &NodeConfig) -> Self {
        let id = &cfg.system_id;
        let slot = &cfg.media_slot;
        Self {
            system: wire::system_path(id),
            reset: wire::reset_path(id),
            vm_collection: wire::virtual_media_collection_path(id),
            vm: wire::virtual_media_path(id, slot),
            vm_insert: wire::virtual_media_action_path(id, slot, wire::INSERT_MEDIA),
            vm_eject: wire::virtual_media_action_path(id, slot, wire::EJECT_MEDIA),
        }
    }
}

/// Everything a request needs: the routes, the node config, its state, its backend.
struct Service {
    routes: Routes,
    cfg: NodeConfig,
    state: Arc<Mutex<NodeState>>,
    backend: Arc<dyn NodeBackend>,
}

impl Service {
    fn dispatch(&self, req: &Request) -> Response {
        let path = req.path.as_str();
        let m = req.method.as_str();

        // The two unauthenticated URIs DSP0266 allows: the protocol-version probe
        // and the service root.
        if path == wire::PROTOCOL_VERSION_PATH {
            return match m {
                "GET" | "HEAD" => Response::json(200, &json!({ "v1": wire::SERVICE_ROOT_PATH })),
                _ => Response::method_not_allowed("GET"),
            };
        }
        if path == wire::SERVICE_ROOT_PATH || path == wire::SERVICE_ROOT_PATH_BARE {
            return match m {
                "GET" | "HEAD" => Response::json(200, &self.service_root()),
                _ => Response::method_not_allowed("GET"),
            };
        }

        if let Some(unauthorized) = self.check_auth(req) {
            return unauthorized;
        }

        let r = &self.routes;
        if path == wire::SYSTEMS_PATH {
            return match m {
                "GET" | "HEAD" => Response::json(200, &self.system_collection()),
                _ => Response::method_not_allowed("GET"),
            };
        }
        if path == wire::SESSIONS_PATH {
            return match m {
                "GET" | "HEAD" => Response::json(200, &self.session_collection()),
                _ => Response::method_not_allowed("GET"),
            };
        }
        if path == r.system {
            return match m {
                "GET" | "HEAD" => Response::json(200, &self.computer_system()),
                "PATCH" => self.patch_system(req),
                _ => Response::method_not_allowed("GET, PATCH"),
            };
        }
        if path == r.reset {
            return match m {
                "POST" => self.post_reset(req),
                _ => Response::method_not_allowed("POST"),
            };
        }
        if path == r.vm_collection {
            return match m {
                "GET" | "HEAD" => Response::json(200, &self.virtual_media_collection()),
                _ => Response::method_not_allowed("GET"),
            };
        }
        if path == r.vm {
            return match m {
                "GET" | "HEAD" => Response::json(200, &self.virtual_media()),
                _ => Response::method_not_allowed("GET"),
            };
        }
        if path == r.vm_insert {
            return match m {
                "POST" => self.post_insert_media(req),
                _ => Response::method_not_allowed("POST"),
            };
        }
        if path == r.vm_eject {
            return match m {
                "POST" => self.post_eject_media(),
                _ => Response::method_not_allowed("POST"),
            };
        }

        Response::error(RESOURCE_NOT_FOUND, &["Resource", path], None)
    }

    /// `None` when the request is authorized. Everything below the service root
    /// needs a Basic credential matching the configured account.
    fn check_auth(&self, req: &Request) -> Option<Response> {
        let ok = req
            .header("authorization")
            .and_then(wire::parse_basic_auth)
            .is_some_and(|(u, p)| u == self.cfg.username && p == self.cfg.password);
        if ok {
            return None;
        }
        Some(
            Response::error(NO_VALID_SESSION, &[], None)
                .with_header("WWW-Authenticate", "Basic realm=\"RedfishService\""),
        )
    }

    // ── resources ───────────────────────────────────────────────────────────

    fn service_root(&self) -> Value {
        json!({
            "@odata.id": wire::SERVICE_ROOT_PATH,
            "@odata.type": odata_type::SERVICE_ROOT,
            "Id": "RootService",
            "Name": "Root Service",
            "RedfishVersion": REDFISH_VERSION,
            "UUID": self.cfg.uuid,
            "Product": "draupnir",
            "Vendor": "nordisk",
            "Systems": { "@odata.id": wire::SYSTEMS_PATH },
            // REQUIRED by the ServiceRoot schema, and it must resolve — hence the
            // (read-only, empty) SessionCollection this service also answers.
            "Links": { "Sessions": { "@odata.id": wire::SESSIONS_PATH } },
        })
    }

    fn system_collection(&self) -> Value {
        json!({
            "@odata.id": wire::SYSTEMS_PATH,
            "@odata.type": odata_type::SYSTEM_COLLECTION,
            "Name": "Computer System Collection",
            "Members": [ { "@odata.id": self.routes.system } ],
            "Members@odata.count": 1,
        })
    }

    /// An EMPTY session collection.
    ///
    /// It exists because `ServiceRoot.Links.Sessions` is a **required** property whose
    /// pointer must resolve; it is empty because this service authenticates with HTTP
    /// Basic only. Session *creation* is a named gap, not a silent one: a `POST` here
    /// is answered `405` with `Allow: GET`, so a client that needs session auth is
    /// told so instead of being handed a plausible-looking token.
    fn session_collection(&self) -> Value {
        json!({
            "@odata.id": wire::SESSIONS_PATH,
            "@odata.type": odata_type::SESSION_COLLECTION,
            "Name": "Session Collection",
            "Members": [],
            "Members@odata.count": 0,
        })
    }

    fn computer_system(&self) -> Value {
        let s = self.state.lock().unwrap();
        let power = self.observe_power(&s);
        let target = s
            .override_target
            .map_or(wire::BOOT_TARGET_NONE, wire::target_str);
        json!({
            "@odata.id": self.routes.system,
            "@odata.type": odata_type::SYSTEM,
            "Id": self.cfg.system_id,
            "Name": self.cfg.system_name,
            "SystemType": "Physical",
            "Manufacturer": self.cfg.manufacturer,
            "Model": self.cfg.model,
            "UUID": self.cfg.uuid,
            "PowerState": power_state_str(power),
            "Status": { "State": "Enabled", "Health": "OK" },
            "Boot": {
                prop::BOOT_SOURCE_OVERRIDE_ENABLED: s.override_enabled,
                prop::BOOT_SOURCE_OVERRIDE_TARGET: target,
                prop::BOOT_SOURCE_OVERRIDE_MODE: "UEFI",
                wire::allowable_values_key(prop::BOOT_SOURCE_OVERRIDE_TARGET):
                    wire::BOOT_TARGET_ALLOWABLE,
            },
            "MemorySummary": { "TotalSystemMemoryGiB": self.cfg.mem_mb as f64 / 1024.0 },
            "ProcessorSummary": { "Count": self.cfg.cores },
            "VirtualMedia": { "@odata.id": self.routes.vm_collection },
            "Actions": {
                wire::ACTION_RESET: {
                    prop::TARGET: self.routes.reset,
                    wire::allowable_values_key(prop::RESET_TYPE): wire::RESET_TYPE_ALLOWABLE,
                }
            },
        })
    }

    fn virtual_media_collection(&self) -> Value {
        json!({
            "@odata.id": self.routes.vm_collection,
            "@odata.type": odata_type::VIRTUAL_MEDIA_COLLECTION,
            "Name": "Virtual Media Services",
            "Members": [ { "@odata.id": self.routes.vm } ],
            "Members@odata.count": 1,
        })
    }

    fn virtual_media(&self) -> Value {
        let s = self.state.lock().unwrap();
        json!({
            "@odata.id": self.routes.vm,
            "@odata.type": odata_type::VIRTUAL_MEDIA,
            "Id": self.cfg.media_slot,
            "Name": "Virtual CD",
            "MediaTypes": ["CD", "DVD"],
            "ConnectedVia": if s.inserted { "URI" } else { "NotConnected" },
            prop::IMAGE: s.image_uri,
            prop::IMAGE_NAME: s.image_uri.as_deref().and_then(basename),
            prop::INSERTED: s.inserted,
            prop::WRITE_PROTECTED: s.write_protected,
            "Actions": {
                wire::ACTION_INSERT_MEDIA: { prop::TARGET: self.routes.vm_insert },
                wire::ACTION_EJECT_MEDIA:  { prop::TARGET: self.routes.vm_eject },
            },
        })
    }

    // ── actions ─────────────────────────────────────────────────────────────

    fn body_json(&self, req: &Request) -> std::result::Result<Value, Response> {
        if req.body.is_empty() {
            return Ok(json!({}));
        }
        serde_json::from_slice(&req.body)
            .map_err(|e| Response::error(MALFORMED_JSON, &[], Some(&format!("Parser said: {e}."))))
    }

    /// `POST .../VirtualMedia/{slot}/Actions/VirtualMedia.InsertMedia`
    fn post_insert_media(&self, req: &Request) -> Response {
        let body = match self.body_json(req) {
            Ok(b) => b,
            Err(r) => return r,
        };
        let insert = match wire::read_insert_media_body(&body) {
            Ok(i) => i,
            Err(missing) => {
                return Response::error(
                    ACTION_PARAMETER_MISSING,
                    &[wire::ACTION_INSERT_MEDIA, missing],
                    None,
                )
            }
        };
        // RED CONTROL: a media URI that resolves to nothing on this host is refused
        // BY NAME, before anything is mounted. An emulator that accepted it would
        // hand a later boot an empty tray and report the failure as "the appliance
        // did not come up" — a different bug with a different owner.
        let path = match resolve_media(&insert.image) {
            Ok(p) => p,
            Err(why) => {
                return Response::error(
                    RESOURCE_MISSING_AT_URI,
                    &[&insert.image],
                    Some(&why),
                )
            }
        };
        let mut s = self.state.lock().unwrap();
        s.image_uri = Some(insert.image.clone());
        s.image_path = Some(path.clone());
        s.inserted = insert.inserted;
        s.write_protected = insert.write_protected;
        drop(s);
        crate::functional_status(
            "draupnir/redfish-server",
            "InsertMedia",
            true,
            &format!("`{}` mounted from `{path}`", insert.image),
        );
        Response::no_content()
    }

    /// `POST .../VirtualMedia/{slot}/Actions/VirtualMedia.EjectMedia`
    fn post_eject_media(&self) -> Response {
        let mut s = self.state.lock().unwrap();
        s.image_uri = None;
        s.image_path = None;
        s.inserted = false;
        s.write_protected = false;
        Response::no_content()
    }

    /// `PATCH /redfish/v1/Systems/{id}` — the one-time boot override.
    fn patch_system(&self, req: &Request) -> Response {
        let body = match self.body_json(req) {
            Ok(b) => b,
            Err(r) => return r,
        };
        let Some(ovr) = wire::read_boot_override_body(&body) else {
            // A PATCH that names nothing this service owns changes nothing. It is not
            // an error (Redfish PATCH is a partial update), and it must not be
            // reported as one.
            return Response::no_content();
        };

        let mut s = self.state.lock().unwrap();
        if let Some(enabled) = &ovr.enabled {
            if !wire::OVERRIDE_ENABLED_ALLOWABLE.contains(&enabled.as_str()) {
                return Response::error(
                    PROPERTY_VALUE_NOT_IN_LIST,
                    &[enabled, prop::BOOT_SOURCE_OVERRIDE_ENABLED],
                    Some(&format!(
                        "Acceptable values: {}.",
                        wire::OVERRIDE_ENABLED_ALLOWABLE.join(", ")
                    )),
                );
            }
            s.override_enabled = enabled.clone();
        }
        if let Some(raw) = &ovr.target {
            if raw == wire::BOOT_TARGET_NONE {
                s.override_target = None;
            } else {
                // A target this node cannot actually honour is REFUSED, never quietly
                // coerced into one it can. `Pxe`/`BiosSetup` are legal DMTF tokens
                // that this KVM front end has nothing to do with, and answering 204
                // for them would be the exact "accepted but not applied" lie.
                match wire::target_from_str(raw)
                    .and_then(|t| BootOrder::from_redfish_target(Some(t)).map(|_| t))
                {
                    Some(t) => s.override_target = Some(t),
                    None => {
                        return Response::error(
                            PROPERTY_VALUE_NOT_IN_LIST,
                            &[raw, prop::BOOT_SOURCE_OVERRIDE_TARGET],
                            Some(&format!(
                                "This node honours: {}.",
                                boot_targets_this_node_honours().join(", ")
                            )),
                        )
                    }
                }
            }
        }
        let applied = format!(
            "{}/{}",
            s.override_enabled,
            s.override_target.map_or(wire::BOOT_TARGET_NONE, wire::target_str)
        );
        drop(s);
        crate::functional_status(
            "draupnir/redfish-server",
            "BootOverride",
            true,
            &format!("boot override set to {applied}"),
        );
        Response::no_content()
    }

    /// `POST /redfish/v1/Systems/{id}/Actions/ComputerSystem.Reset`
    fn post_reset(&self, req: &Request) -> Response {
        let body = match self.body_json(req) {
            Ok(b) => b,
            Err(r) => return r,
        };
        let reset_type = match wire::read_reset_body(&body) {
            Ok(t) => t,
            Err(missing) => {
                return Response::error(
                    ACTION_PARAMETER_MISSING,
                    &[wire::ACTION_RESET, missing],
                    None,
                )
            }
        };
        // RED CONTROL: a ResetType outside the advertised list is refused BY NAME,
        // quoting the value, the parameter and the action — DMTF's own
        // ActionParameterValueNotInList.
        if !wire::is_allowable_reset_type(&reset_type) {
            return Response::error(
                ACTION_PARAMETER_VALUE_NOT_IN_LIST,
                &[&reset_type, prop::RESET_TYPE, wire::ACTION_RESET],
                Some(&format!(
                    "Acceptable values: {}.",
                    wire::RESET_TYPE_ALLOWABLE.join(", ")
                )),
            );
        }

        match reset_type.as_str() {
            wire::RESET_ON | wire::RESET_FORCE_ON => self.power_up(&reset_type),
            wire::RESET_FORCE_OFF | wire::RESET_GRACEFUL_SHUTDOWN => self.power_down(&reset_type),
            wire::RESET_FORCE_RESTART | wire::RESET_GRACEFUL_RESTART => {
                self.power_down(&reset_type);
                self.power_up(&reset_type)
            }
            // Unreachable while RESET_TYPE_ALLOWABLE and this match agree; if they
            // ever stop agreeing, say so instead of pretending to act.
            other => Response::error(
                ACTION_PARAMETER_VALUE_NOT_IN_LIST,
                &[other, prop::RESET_TYPE, wire::ACTION_RESET],
                Some("This service advertises the value but does not implement it."),
            ),
        }
    }

    /// Power the node on: build the [`BootSpec`] the current state describes and hand
    /// it to the backend. **This is where the override becomes applied output** — the
    /// spec that leaves here is the machine that gets booted.
    fn power_up(&self, reset_type: &str) -> Response {
        let spec = {
            let s = self.state.lock().unwrap();
            match self.spec_for(&s) {
                Ok(spec) => spec,
                Err(why) => {
                    crate::functional_status(
                        "draupnir/redfish-server",
                        "Reset",
                        false,
                        &format!("{reset_type} refused: {why}"),
                    );
                    return Response::error(GENERAL_ERROR, &[], Some(&why));
                }
            }
        };
        match self.backend.boot(&spec) {
            Ok(machine) => {
                let mut s = self.state.lock().unwrap();
                s.machine = Some(machine);
                s.last_spec = Some(spec.clone());
                s.last_reset = Some(reset_type.to_string());
                // A ONE-TIME override is CONSUMED by the boot it applied to — DSP0266
                // says the service resets it to Disabled/None. Modelling that is what
                // stops a stale `Once` from silently steering a later boot.
                if s.override_enabled == wire::OVERRIDE_ONCE {
                    s.override_enabled = wire::OVERRIDE_DISABLED.to_string();
                    s.override_target = None;
                }
                drop(s);
                crate::functional_status(
                    "draupnir/redfish-server",
                    "Reset",
                    true,
                    &format!(
                        "{reset_type}: booted `{}` (order {:?}, medium {:?})",
                        spec.name,
                        spec.boot_order,
                        spec.medium_path()
                    ),
                );
                Response::no_content()
            }
            Err(e) => {
                crate::functional_status(
                    "draupnir/redfish-server",
                    "Reset",
                    false,
                    &format!("{reset_type} failed: {e}"),
                );
                Response::error(GENERAL_ERROR, &[], Some(&format!("The node's backend refused to start: {e}")))
            }
        }
    }

    fn power_down(&self, reset_type: &str) -> Response {
        let machine = self.state.lock().unwrap().machine.clone();
        let Some(machine) = machine else {
            // Powering off an already-off node is a no-op, not a fault.
            return Response::no_content();
        };
        match self.backend.power_off(&machine) {
            Ok(()) => {
                let mut s = self.state.lock().unwrap();
                s.machine = None;
                s.last_reset = Some(reset_type.to_string());
                Response::no_content()
            }
            Err(e) => Response::error(
                GENERAL_ERROR,
                &[],
                Some(&format!("The node's backend refused to power off: {e}")),
            ),
        }
    }

    /// **The translation, in one function.** Redfish state → a draupnir [`BootSpec`].
    ///
    /// The boot order comes from [`BootOrder::from_redfish_target`] — the inverse of
    /// the mapping the CLIENT uses — and then each order selects the constructor that
    /// already expresses it:
    ///
    /// * [`BootOrder::Medium`] → [`BootSpec::iso_boot`] (the inserted media is the
    ///   whole payload; `-cdrom` + `-boot d`).
    /// * [`BootOrder::Disk`] → [`BootSpec::kvm_boot_installed_disk`], which by
    ///   construction carries **no medium at all** ([`BootSpec::validate`] refuses
    ///   `Disk` + a medium). That is what makes an `Hdd` override *applied* rather
    ///   than merely acknowledged: the ISO is not attached to the machine.
    /// * [`BootOrder::Auto`] → the node's own order: the medium if a tray is loaded,
    ///   else the local disk.
    ///
    /// Every refusal names what was missing, because "the appliance did not come up"
    /// and "you never inserted an ISO" are different failures with different owners.
    fn spec_for(&self, s: &NodeState) -> std::result::Result<BootSpec, String> {
        let effective_target = if s.override_enabled == wire::OVERRIDE_DISABLED {
            None
        } else {
            s.override_target
        };
        let order = BootOrder::from_redfish_target(effective_target).ok_or_else(|| {
            format!(
                "boot override target {:?} is not a boot order this node can apply",
                effective_target
            )
        })?;

        let name = format!("redfish-{}", self.cfg.system_id);
        let medium = s.inserted.then(|| s.image_path.clone()).flatten();

        let mut spec = match order {
            BootOrder::Medium => {
                let iso = medium.ok_or_else(|| {
                    format!(
                        "the boot override asks for {} but no virtual media is inserted in slot `{}`",
                        wire::target_str(BootTarget::Cd),
                        self.cfg.media_slot
                    )
                })?;
                BootSpec::iso_boot(name, iso)
            }
            BootOrder::Disk => {
                let disk = self.cfg.local_disk.clone().ok_or_else(|| {
                    format!(
                        "the boot override asks for {} but this node has no local disk configured \
                         (NodeConfig::local_disk)",
                        wire::target_str(BootTarget::Hdd)
                    )
                })?;
                BootSpec::kvm_boot_installed_disk(name, disk)
            }
            // The CD first and the disk after it: the installed disk with the
            // inserted medium beside it, `-boot order=dc`. It needs BOTH — without
            // a medium there is nothing to boot first, without a disk nothing to
            // fall through to — and says which is missing.
            BootOrder::MediumThenDisk => {
                let iso = medium.ok_or_else(|| {
                    format!(
                        "the boot override asks for {} then the disk, but no virtual media is inserted in slot `{}`",
                        wire::target_str(BootTarget::Cd),
                        self.cfg.media_slot
                    )
                })?;
                let disk = self.cfg.local_disk.clone().ok_or_else(|| {
                    "the boot override asks for the medium then the disk, but this node has no local disk \
                     configured (NodeConfig::local_disk) to fall through to"
                        .to_string()
                })?;
                BootSpec::kvm_boot_installed_disk(name, disk).with_medium(iso).with_boot_order(BootOrder::MediumThenDisk)
            }
            BootOrder::DiskThenMedium => {
                let iso = medium.ok_or("the disk-then-CD order needs virtual media inserted")?;
                let disk = self.cfg.local_disk.clone().ok_or("the disk-then-CD order needs a local disk (NodeConfig::local_disk)")?;
                BootSpec::kvm_boot_installed_disk(name, disk).with_medium(iso).with_boot_order(BootOrder::DiskThenMedium)
            }
            BootOrder::Auto => match (medium, self.cfg.local_disk.clone()) {
                (Some(iso), _) => BootSpec::iso_boot(name, iso),
                (None, Some(disk)) => BootSpec::kvm_boot_installed_disk(name, disk),
                (None, None) => {
                    return Err(
                        "this node has neither virtual media inserted nor a local disk to boot"
                            .into(),
                    )
                }
            },
        };
        spec.mem_mb = self.cfg.mem_mb;
        spec.cores = self.cfg.cores;
        // Validate here, so a spec this service could not have booted is refused with
        // draupnir's own reason rather than QEMU's, from inside the Redfish action.
        spec.validate().map_err(|e| e.to_string())?;
        Ok(spec)
    }

    /// The node's power state, read from the backend when there is a machine.
    fn observe_power(&self, s: &NodeState) -> PowerState {
        match &s.machine {
            None => PowerState::Off,
            Some(m) => self.backend.status(m).unwrap_or(PowerState::Unknown),
        }
    }
}

/// The `BootSourceOverrideTarget` tokens this node will actually apply — the subset
/// of [`wire::BOOT_TARGET_ALLOWABLE`] that maps onto a [`BootOrder`].
fn boot_targets_this_node_honours() -> Vec<&'static str> {
    wire::BOOT_TARGET_ALLOWABLE
        .iter()
        .copied()
        .filter(|t| {
            wire::target_from_str(t)
                .and_then(|bt| BootOrder::from_redfish_target(Some(bt)))
                .is_some()
        })
        .chain(std::iter::once(wire::BOOT_TARGET_NONE))
        .collect()
}

/// The Redfish `PowerState` token for a draupnir [`PowerState`].
fn power_state_str(p: PowerState) -> &'static str {
    match p {
        PowerState::On => "On",
        PowerState::Off => "Off",
        // DMTF has no "unknown" PowerState token; the honest report for a node whose
        // state cannot be observed is to omit the claim, and `Off` would be a claim.
        PowerState::Unknown => "Paused",
    }
}

/// The last path segment of a media URI, for `VirtualMedia.ImageName`.
fn basename(uri: &str) -> Option<String> {
    uri.rsplit(['/', '\\']).next().map(str::to_string).filter(|s| !s.is_empty())
}

/// **Resolve a Redfish media URI onto a local file.**
///
/// A real BMC *pulls* the image over http(s)/NFS/CIFS. This service fronts local
/// KVM, so it resolves a `file://` URI or an absolute path and **refuses a remote
/// scheme by name** rather than pretending to have fetched it. That limitation is a
/// property of the emulator, not of the protocol: draupnir's client is unchanged and
/// still sends whatever URI a real BMC would be given.
fn resolve_media(uri: &str) -> std::result::Result<String, String> {
    let path = if let Some(rest) = uri.strip_prefix("file://") {
        // `file:///abs/path` — the authority is empty for a local file.
        let rest = rest.strip_prefix("localhost").unwrap_or(rest);
        if !rest.starts_with('/') {
            return Err(format!(
                "This service resolves `file://` URIs with an absolute path; `{uri}` has none."
            ));
        }
        percent_decode(rest)
    } else if uri.starts_with('/') {
        uri.to_string()
    } else {
        let scheme = uri.split_once("://").map(|(s, _)| s).unwrap_or("<none>");
        return Err(format!(
            "This BMC fronts local KVM and mounts only `file://` URIs or absolute paths; \
             it does not fetch `{scheme}` media. A real BMC would."
        ));
    };
    if std::path::Path::new(&path).is_file() {
        Ok(path)
    } else {
        Err(format!(
            "No file exists at `{path}` on the host running this service."
        ))
    }
}

// ===========================================================================
// Unit tests — the PURE halves. The wire conformance lives in
// tests/redfish_server_conformance.rs, anchored to DMTF's own fixtures.
// ===========================================================================

#[cfg(test)]
mod tests {
    use super::*;

    /// The registry templates render with their arguments substituted — the check
    /// that a `MessageArgs` array and the `Message` string actually agree.
    #[test]
    fn registry_messages_render_their_arguments() {
        assert_eq!(
            ACTION_PARAMETER_VALUE_NOT_IN_LIST.render(&[
                "Reboot",
                prop::RESET_TYPE,
                wire::ACTION_RESET
            ]),
            "The value 'Reboot' for the parameter ResetType in the action \
             #ComputerSystem.Reset is not in the list of acceptable values."
        );
        assert_eq!(
            RESOURCE_MISSING_AT_URI.render(&["file:///nope.iso"]),
            "The resource at the URI 'file:///nope.iso' was not found."
        );
        // A zero-arg message renders verbatim.
        assert_eq!(NO_VALID_SESSION.render(&[]), NO_VALID_SESSION.template);
        // No `%N` may survive a full substitution.
        for m in ALL_MESSAGES {
            let args: Vec<&str> = (0..m.nargs).map(|_| "X").collect();
            let out = m.render(&args);
            assert!(!out.contains('%'), "{} left a placeholder: {out}", m.id);
        }
    }

    #[test]
    fn message_ids_are_registry_qualified() {
        assert_eq!(
            RESOURCE_MISSING_AT_URI.message_id(),
            "Base.1.19.0.ResourceMissingAtURI"
        );
        for m in ALL_MESSAGES {
            assert!(
                m.message_id().starts_with("Base.1.19.0."),
                "{} is unqualified",
                m.id
            );
        }
    }

    /// The routing table is built from the same `wire` functions the client builds
    /// its URLs from. RED-when-broken: hand-type any route and this fails.
    #[test]
    fn every_route_is_the_path_the_client_would_ask_for() {
        let cfg = NodeConfig::new("System.Embedded.1").media_slot("CD");
        let r = Routes::for_node(&cfg);
        assert_eq!(r.system, "/redfish/v1/Systems/System.Embedded.1");
        assert_eq!(
            r.reset,
            "/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
        );
        assert_eq!(
            r.vm_insert,
            "/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia"
        );
        assert_eq!(
            r.vm_eject,
            "/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.EjectMedia"
        );
        assert_eq!(r.vm, "/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD");
        assert_eq!(
            r.vm_collection,
            "/redfish/v1/Systems/System.Embedded.1/VirtualMedia"
        );
    }

    #[test]
    fn media_resolution_refuses_by_name() {
        // A remote scheme is named, not silently swallowed.
        let e = resolve_media("https://depot/x.iso").unwrap_err();
        assert!(e.contains("https"), "names the scheme: {e}");
        // A path that is not there is named.
        let e = resolve_media("/nonexistent/never-built.iso").unwrap_err();
        assert!(e.contains("/nonexistent/never-built.iso"), "{e}");
        // A directory is not a medium.
        assert!(resolve_media("/tmp").is_err());
        // A real file resolves, by path and by file:// URI.
        let real = std::env::current_exe().unwrap();
        let real = real.to_string_lossy().into_owned();
        assert_eq!(resolve_media(&real).unwrap(), real);
        assert_eq!(resolve_media(&format!("file://{real}")).unwrap(), real);
        // Percent-encoding is DECODED, so a media path with a space or a non-ASCII
        // character (this box keeps its ISOs under `Hämtningar`) resolves.
        let odd = std::env::temp_dir().join(format!("draupnir rf-server å-{}.iso", std::process::id()));
        std::fs::write(&odd, b"medium").unwrap();
        let encoded = odd
            .to_string_lossy()
            .replace(' ', "%20")
            .replace('å', "%C3%A5");
        assert_eq!(
            resolve_media(&format!("file://{encoded}")).unwrap(),
            odd.to_string_lossy()
        );
        let _ = std::fs::remove_file(&odd);
    }

    #[test]
    fn percent_decode_handles_utf8_and_leaves_junk_alone() {
        assert_eq!(percent_decode("/H%C3%A4mtningar"), "/Hämtningar");
        assert_eq!(percent_decode("/a%zz"), "/a%zz", "invalid escape survives");
        assert_eq!(percent_decode("/plain"), "/plain");
    }

    #[test]
    fn basename_is_the_last_segment() {
        assert_eq!(basename("file:///a/b/c.iso").as_deref(), Some("c.iso"));
        assert_eq!(basename("x.iso").as_deref(), Some("x.iso"));
        assert_eq!(basename("/a/b/").as_deref(), None);
    }

    /// The node advertises exactly the targets it can apply, plus `None`. A token in
    /// `BOOT_TARGET_ALLOWABLE` that maps to no `BootOrder` must not appear here.
    #[test]
    fn the_node_honours_only_the_targets_it_can_actually_apply() {
        let honoured = boot_targets_this_node_honours();
        assert!(honoured.contains(&"Cd"));
        assert!(honoured.contains(&"Hdd"));
        assert!(honoured.contains(&"None"));
        assert!(!honoured.contains(&"Pxe"), "no PXE on a KVM front end");
        assert!(!honoured.contains(&"BiosSetup"));
    }

    /// A backend that records the spec it was handed and never launches anything —
    /// the same shape the conformance suite uses.
    #[derive(Default)]
    struct Recorder {
        specs: Mutex<Vec<BootSpec>>,
    }
    impl Boot for Recorder {
        fn boot(&self, spec: &BootSpec) -> Result<Machine> {
            self.specs.lock().unwrap().push(spec.clone());
            Ok(Machine::started(format!("rec-{}", spec.name), spec))
        }
    }
    impl Lifecycle for Recorder {
        fn power_on(&self, _m: &Machine) -> Result<()> {
            Ok(())
        }
        fn power_off(&self, _m: &Machine) -> Result<()> {
            Ok(())
        }
        fn status(&self, _m: &Machine) -> Result<PowerState> {
            Ok(PowerState::On)
        }
    }

    fn service_with(cfg: NodeConfig) -> Service {
        Service {
            routes: Routes::for_node(&cfg),
            cfg,
            state: Arc::new(Mutex::new(NodeState::fresh())),
            backend: Arc::new(Recorder::default()),
        }
    }

    /// **The translation, proven at the spec level.** A `Cd` override with media in
    /// the tray produces a spec that boots OFF the medium; an `Hdd` override produces
    /// one that carries NO medium at all.
    #[test]
    fn the_boot_override_selects_which_spec_the_node_boots() {
        let iso = std::env::current_exe().unwrap().to_string_lossy().into_owned();
        let svc = service_with(
            NodeConfig::new("s1")
                .local_disk("/var/lib/node.qcow2")
                .sized(2048, 4),
        );
        {
            let mut s = svc.state.lock().unwrap();
            s.image_uri = Some(iso.clone());
            s.image_path = Some(iso.clone());
            s.inserted = true;
            s.override_enabled = wire::OVERRIDE_ONCE.into();
            s.override_target = Some(BootTarget::Cd);
        }
        let spec = svc.spec_for(&svc.state.lock().unwrap()).unwrap();
        assert_eq!(spec.boot_order, BootOrder::Medium);
        assert_eq!(spec.medium_path(), Some(iso.as_str()));
        assert_eq!(spec.mem_mb, 2048);
        assert_eq!(spec.cores, 4);

        // Flip ONLY the override. The media stays in the tray, exactly as it would on
        // a real machine — and the spec must still stop naming it.
        svc.state.lock().unwrap().override_target = Some(BootTarget::Hdd);
        let spec = svc.spec_for(&svc.state.lock().unwrap()).unwrap();
        assert_eq!(spec.boot_order, BootOrder::Disk);
        assert_eq!(
            spec.medium_path(),
            None,
            "an Hdd override must not leave the installer medium attached — that is \
             how a boot override gets accepted without ever being applied"
        );
    }

    #[test]
    fn an_override_the_node_cannot_apply_is_refused_by_name_not_coerced() {
        let svc = service_with(NodeConfig::new("s1"));
        let req = Request {
            method: "PATCH".into(),
            path: wire::system_path("s1"),
            headers: BTreeMap::new(),
            body: serde_json::to_vec(&json!({
                "Boot": { "BootSourceOverrideTarget": "Pxe" }
            }))
            .unwrap(),
        };
        let resp = svc.patch_system(&req);
        assert_eq!(resp.status, 400);
        let v: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(v["error"]["code"], "Base.1.19.0.PropertyValueNotInList");
        assert!(
            v["error"]["message"].as_str().unwrap().contains("Pxe"),
            "the refusal quotes the value: {v}"
        );
        // ...and nothing was applied.
        assert_eq!(svc.state.lock().unwrap().override_target, None);
    }

    #[test]
    fn a_cd_override_with_an_empty_tray_refuses_instead_of_booting_something_else() {
        let svc = service_with(NodeConfig::new("s1").local_disk("/var/lib/node.qcow2"));
        {
            let mut s = svc.state.lock().unwrap();
            s.override_enabled = wire::OVERRIDE_ONCE.into();
            s.override_target = Some(BootTarget::Cd);
        }
        let err = svc.spec_for(&svc.state.lock().unwrap()).unwrap_err();
        assert!(err.contains("no virtual media is inserted"), "{err}");
        // The configured disk is NOT quietly substituted.
        assert!(!err.contains("qcow2"), "{err}");
    }

    #[test]
    fn a_hdd_override_on_a_diskless_node_refuses_instead_of_falling_back_to_the_medium() {
        let iso = std::env::current_exe().unwrap().to_string_lossy().into_owned();
        let svc = service_with(NodeConfig::new("s1")); // no local disk
        {
            let mut s = svc.state.lock().unwrap();
            s.image_path = Some(iso);
            s.inserted = true;
            s.override_enabled = wire::OVERRIDE_ONCE.into();
            s.override_target = Some(BootTarget::Hdd);
        }
        let err = svc.spec_for(&svc.state.lock().unwrap()).unwrap_err();
        assert!(err.contains("no local disk"), "{err}");
    }

    /// A one-time override is CONSUMED by the boot that applied it.
    #[test]
    fn a_once_override_does_not_steer_the_next_boot_too() {
        let iso = std::env::current_exe().unwrap().to_string_lossy().into_owned();
        let svc = service_with(NodeConfig::new("s1"));
        {
            let mut s = svc.state.lock().unwrap();
            s.image_uri = Some(iso.clone());
            s.image_path = Some(iso);
            s.inserted = true;
            s.override_enabled = wire::OVERRIDE_ONCE.into();
            s.override_target = Some(BootTarget::Cd);
        }
        assert_eq!(svc.power_up(wire::RESET_ON).status, 204);
        let s = svc.state.lock().unwrap();
        assert_eq!(s.override_enabled, wire::OVERRIDE_DISABLED);
        assert_eq!(s.override_target, None);
        assert_eq!(s.last_spec.as_ref().unwrap().boot_order, BootOrder::Medium);
    }

    #[test]
    fn a_bad_reset_type_is_refused_and_nothing_boots() {
        let svc = service_with(NodeConfig::new("s1"));
        let req = Request {
            method: "POST".into(),
            path: wire::reset_path("s1"),
            headers: BTreeMap::new(),
            body: serde_json::to_vec(&json!({ "ResetType": "Reboot" })).unwrap(),
        };
        let resp = svc.post_reset(&req);
        assert_eq!(resp.status, 400);
        let v: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(
            v["error"]["code"],
            "Base.1.19.0.ActionParameterValueNotInList"
        );
        assert_eq!(v["error"]["@Message.ExtendedInfo"][0]["MessageArgs"][0], "Reboot");
        assert!(svc.state.lock().unwrap().machine.is_none(), "nothing booted");
    }

    #[test]
    fn a_reset_with_no_reset_type_names_the_missing_parameter() {
        let svc = service_with(NodeConfig::new("s1"));
        let req = Request {
            method: "POST".into(),
            path: wire::reset_path("s1"),
            headers: BTreeMap::new(),
            body: b"{}".to_vec(),
        };
        let resp = svc.post_reset(&req);
        assert_eq!(resp.status, 400);
        let v: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(v["error"]["code"], "Base.1.19.0.ActionParameterMissing");
        assert!(v["error"]["message"].as_str().unwrap().contains("ResetType"));
    }

    #[test]
    fn insert_media_pointing_at_nothing_is_refused_by_name() {
        let svc = service_with(NodeConfig::new("s1"));
        let req = Request {
            method: "POST".into(),
            path: svc.routes.vm_insert.clone(),
            headers: BTreeMap::new(),
            body: serde_json::to_vec(&wire::insert_media_body("/nonexistent/never-built.iso"))
                .unwrap(),
        };
        let resp = svc.post_insert_media(&req);
        assert_eq!(resp.status, 400);
        let v: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(v["error"]["code"], "Base.1.19.0.ResourceMissingAtURI");
        assert!(v["error"]["message"]
            .as_str()
            .unwrap()
            .contains("/nonexistent/never-built.iso"));
        // Nothing was mounted.
        assert!(!svc.state.lock().unwrap().inserted);
    }

    #[test]
    fn a_method_the_resource_does_not_take_answers_405_with_allow() {
        let svc = service_with(NodeConfig::new("s1").credentials("admin", "pw"));
        let auth = wire::basic_auth_header("admin", "pw");
        let mut headers = BTreeMap::new();
        headers.insert("authorization".to_string(), auth);
        // GET on an action target.
        let req = Request {
            method: "GET".into(),
            path: wire::reset_path("s1"),
            headers: headers.clone(),
            body: Vec::new(),
        };
        let resp = svc.dispatch(&req);
        assert_eq!(resp.status, 405);
        assert!(resp
            .headers
            .iter()
            .any(|(k, v)| k == "Allow" && v == "POST"));
        // POST on the ComputerSystem.
        let req = Request {
            method: "POST".into(),
            path: wire::system_path("s1"),
            headers,
            body: Vec::new(),
        };
        let resp = svc.dispatch(&req);
        assert_eq!(resp.status, 405);
        assert!(resp
            .headers
            .iter()
            .any(|(k, v)| k == "Allow" && v == "GET, PATCH"));
    }

    #[test]
    fn everything_below_the_service_root_needs_a_credential() {
        let svc = service_with(NodeConfig::new("s1").credentials("admin", "pw"));
        let unauth = |path: &str| Request {
            method: "GET".into(),
            path: path.into(),
            headers: BTreeMap::new(),
            body: Vec::new(),
        };
        // The two DSP0266 unauthenticated URIs are reachable...
        assert_eq!(svc.dispatch(&unauth(wire::PROTOCOL_VERSION_PATH)).status, 200);
        assert_eq!(svc.dispatch(&unauth(wire::SERVICE_ROOT_PATH)).status, 200);
        // ...and nothing else is.
        for p in [
            wire::SYSTEMS_PATH,
            &wire::system_path("s1"),
            &wire::virtual_media_path("s1", "CD"),
        ] {
            let r = svc.dispatch(&unauth(p));
            assert_eq!(r.status, 401, "{p}");
            assert!(r.headers.iter().any(|(k, _)| k == "WWW-Authenticate"), "{p}");
        }
        // A WRONG password is not a credential either.
        let mut headers = BTreeMap::new();
        headers.insert(
            "authorization".into(),
            wire::basic_auth_header("admin", "wrong"),
        );
        let req = Request {
            method: "GET".into(),
            path: wire::system_path("s1"),
            headers,
            body: Vec::new(),
        };
        assert_eq!(svc.dispatch(&req).status, 401);
    }

    #[test]
    fn an_unknown_uri_is_a_dmtf_shaped_404_naming_the_uri() {
        let svc = service_with(NodeConfig::new("s1").credentials("admin", ""));
        let mut headers = BTreeMap::new();
        headers.insert("authorization".into(), wire::basic_auth_header("admin", ""));
        let req = Request {
            method: "GET".into(),
            path: "/redfish/v1/Systems/other".into(),
            headers,
            body: Vec::new(),
        };
        let resp = svc.dispatch(&req);
        assert_eq!(resp.status, 404);
        let v: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(v["error"]["code"], "Base.1.19.0.ResourceNotFound");
        assert!(v["error"]["message"]
            .as_str()
            .unwrap()
            .contains("/redfish/v1/Systems/other"));
    }

    #[test]
    fn malformed_json_is_reported_as_malformed_json() {
        let svc = service_with(NodeConfig::new("s1"));
        let req = Request {
            method: "POST".into(),
            path: wire::reset_path("s1"),
            headers: BTreeMap::new(),
            body: b"{not json".to_vec(),
        };
        let resp = svc.post_reset(&req);
        assert_eq!(resp.status, 400);
        let v: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(v["error"]["code"], "Base.1.19.0.MalformedJSON");
    }

    #[test]
    fn a_freshly_started_node_reports_itself_off_with_an_empty_tray() {
        let svc = service_with(NodeConfig::new("s1"));
        let sys = svc.computer_system();
        assert_eq!(sys["PowerState"], "Off");
        assert_eq!(sys["Boot"]["BootSourceOverrideEnabled"], "Disabled");
        assert_eq!(sys["Boot"]["BootSourceOverrideTarget"], "None");
        let vm = svc.virtual_media();
        assert_eq!(vm["Inserted"], false);
        assert!(vm["Image"].is_null());
    }
}