Gunnar

mikronova/draupnir

src/redfish.rs

//! **Redfish backend** — provision **bare metal** out-of-band through a BMC.
//!
//! This is the capability Draupnir uniquely owns. A bare-metal node is booted
//! *without* touching its OS: through the node's BMC (iLO / iDRAC / OpenBMC)
//! DMTF **Redfish** REST API we
//!
//! 1. **insert** a bootable ISO as **virtual media** ([`VirtualMedia::insert_media`]),
//! 2. **set the one-time boot override** to that media ([`VirtualMedia::set_boot_override`]),
//! 3. **power the node on** ([`Lifecycle::power_on`], via `ComputerSystem.Reset`).
//!
//! Draupnir does not hand-roll a heavyweight client: the transport is **`ureq`**
//! over **rustls** (pure Rust — no openssl/native-tls, per the charter), driving the
//! handful of Redfish endpoints we need. It sits behind the `backend-redfish`
//! feature so the default build stays pure-std and offline-green.
//!
//! ## Testability
//!
//! The **request shaping** — the resource URLs and the JSON action bodies — is
//! pure and [fixture-tested](tests) with no BMC. Only the actual HTTP send needs a
//! live endpoint; unit tests never touch the network. BMCs almost always ship
//! self-signed certs; the *secure* way to trust one is to **pin** it with
//! [`RedfishBoot::pin_cert_pem`] (rustls verification stays ON, only that cert is
//! trusted). [`RedfishBoot::insecure`] — which disables verification entirely — is a
//! last-resort fall-back only. The BMC secret is also **redacted** in the
//! [`Debug`](std::fmt::Debug) impl so it never leaks to a log.

use crate::{
    BmcEndpoint, Boot, BootSpec, BootTarget, Error, Lifecycle, Machine, PowerState,
    Result, VirtualMedia,
};

/// The default Redfish `VirtualMedia` resource id an ISO is inserted into (the
/// virtual CD/DVD slot). Overridable via [`RedfishBoot::media_id`].
pub const DEFAULT_MEDIA_ID: &str = "CD";

/// The Redfish bare-metal boot backend.
///
/// Constructed either bare ([`new`](Self::new), then [`with_password`](Self::with_password))
/// or bound to a node ([`for_node`](Self::for_node)). The BMC secret is supplied
/// here **at drive time** — it is deliberately never a field on [`BootSpec`].
///
/// ## TLS trust
///
/// The BMC password is only ever sent over **https** — [`require_https`] rejects a
/// non-https [`BmcEndpoint::host`] *before* the Basic-auth header is attached, so a
/// credential can never leak in cleartext (or to an `http://` typo). BMCs almost
/// always present a self-signed cert; the secure way to trust it is to **pin** that
/// cert with [`pin_cert_pem`](Self::pin_cert_pem) (verification stays ON, only that
/// one cert is trusted). [`insecure`](Self::insecure) — which disables *all* cert
/// verification and thus offers no MITM protection — remains only as a last resort
/// when no pinned cert is obtainable.
#[derive(Default, Clone)]
pub struct RedfishBoot {
    /// The BMC account password, supplied at drive time (never in a [`BootSpec`]).
    password: String,
    /// Last-resort escape hatch: skip **all** TLS certificate verification. Prefer
    /// [`pin_cert_pem`](Self::pin_cert_pem). Ignored when a pinned cert is set.
    insecure: bool,
    /// A pinned BMC certificate (PEM). When set, TLS verification stays ON and this
    /// cert is the *sole* trusted root — the secure way to accept a self-signed BMC.
    #[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
    pinned_cert_pem: Option<Vec<u8>>,
    /// The `VirtualMedia` slot id (defaults to [`DEFAULT_MEDIA_ID`]).
    media_id: Option<String>,
    /// The node this backend drives, for the [`Lifecycle`] methods (which only get
    /// a [`Machine`], not a [`BmcEndpoint`]). `boot()` uses the spec's BMC directly.
    #[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
    endpoint: Option<BmcEndpoint>,
}

// Hand-rolled Debug: never render the BMC password or the raw pinned-cert bytes.
// The derived Debug would print the secret verbatim (SECURITY: LENS-4 finding).
impl std::fmt::Debug for RedfishBoot {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RedfishBoot")
            .field(
                "password",
                &if self.password.is_empty() {
                    "<unset>"
                } else {
                    "<redacted>"
                },
            )
            .field("insecure", &self.insecure)
            .field(
                "pinned_cert_pem",
                &self.pinned_cert_pem.as_ref().map(|_| "<pinned>"),
            )
            .field("media_id", &self.media_id)
            .field("endpoint", &self.endpoint)
            .finish()
    }
}

impl RedfishBoot {
    /// Construct the Redfish backend (no credential/endpoint yet).
    pub fn new() -> Self {
        Self::default()
    }

    /// Bind the backend to a node + its BMC password so the [`Lifecycle`] methods
    /// (which receive only a [`Machine`]) can address it.
    pub fn for_node(bmc: BmcEndpoint, password: impl Into<String>) -> Self {
        Self {
            password: password.into(),
            insecure: false,
            pinned_cert_pem: None,
            media_id: None,
            endpoint: Some(bmc),
        }
    }

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

    /// **Pin** the BMC's certificate (PEM) as the sole trusted root — the *secure*
    /// way to accept the self-signed cert BMCs ship: TLS verification stays ON and
    /// only this exact cert is trusted, so the connection is still MITM-resistant.
    /// Prefer this over [`insecure`](Self::insecure). Takes precedence over it.
    pub fn pin_cert_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
        self.pinned_cert_pem = Some(pem.into());
        self
    }

    /// **Last-resort** escape hatch: skip *all* TLS certificate verification. This
    /// removes MITM protection — the BMC Basic-auth secret is exposed to any
    /// on-path attacker who can present a cert. Prefer [`pin_cert_pem`](Self::pin_cert_pem);
    /// a pinned cert overrides this flag. The transport still requires **https**
    /// regardless, so the credential is never sent in cleartext.
    pub fn insecure(mut self, insecure: bool) -> Self {
        self.insecure = insecure;
        self
    }

    /// Override the `VirtualMedia` slot id (default [`DEFAULT_MEDIA_ID`]).
    pub fn media_id(mut self, id: impl Into<String>) -> Self {
        self.media_id = Some(id.into());
        self
    }

    /// The virtual-media slot this backend inserts into.
    #[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
    fn slot(&self) -> &str {
        self.media_id.as_deref().unwrap_or(DEFAULT_MEDIA_ID)
    }

    /// The BMC endpoint + ISO a Redfish spec targets (both required).
    fn target<'a>(&self, spec: &'a BootSpec) -> Result<(&'a BmcEndpoint, &'a str)> {
        let bmc = spec
            .bmc
            .as_ref()
            .ok_or_else(|| Error::Spec("Redfish boot needs a BMC endpoint".into()))?;
        // Read the medium through the ONE shared accessor, so "the medium" means the
        // same thing here as it does in the KVM backend: an explicit
        // `BootSpec::medium` wins, else an `ImageSource::Iso` payload. Without this
        // an `iso_boot(..).on_metal(bmc)` spec — the whole of honesty rule 2 — would
        // be understood by one backend and rejected by the other.
        match spec.medium_path() {
            Some(iso) => Ok((bmc, iso)),
            None => Err(Error::Spec(format!(
                "Redfish backend boots an ISO medium, got {:?} with no medium",
                spec.image
            ))),
        }
    }

    /// The node the [`Lifecycle`] methods act on (the one bound via
    /// [`for_node`](Self::for_node)).
    #[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
    fn bound(&self) -> Result<&BmcEndpoint> {
        self.endpoint.as_ref().ok_or_else(|| {
            Error::Backend(
                "no BMC endpoint bound: construct with RedfishBoot::for_node(bmc, password)".into(),
            )
        })
    }
}

impl Boot for RedfishBoot {
    fn boot(&self, spec: &BootSpec) -> Result<Machine> {
        spec.validate()?;
        let (bmc, iso) = self.target(spec)?;
        #[cfg(feature = "backend-redfish")]
        {
            // The full bare-metal boot sequence: insert the ISO, override next boot
            // to it, power the node on — each an out-of-band Redfish action.
            self.insert_media(bmc, iso)?;
            // The spec's boot order, translated into Redfish's vocabulary by the
            // shared `BootOrder::redfish_target()` — the same field the KVM backend
            // renders as `-boot d`. `Auto` (every spec written before boot order
            // existed, including `BootSpec::redfish_iso`) keeps the long-standing
            // one-time `Cd` override, so this call is unchanged for them.
            let target = spec.boot_order.redfish_target().unwrap_or(BootTarget::Cd);
            self.set_boot_override(bmc, target)?;
            self.reset(bmc, "On")?;
            Ok(Machine::started(&bmc.system_id, spec))
        }
        #[cfg(not(feature = "backend-redfish"))]
        {
            let _ = (bmc, iso);
            Err(Error::Unsupported(
                "redfish backend needs the `backend-redfish` feature (pure-Rust ureq/rustls Redfish client)"
                    .into(),
            ))
        }
    }
}

impl VirtualMedia for RedfishBoot {
    fn insert_media(&self, node: &BmcEndpoint, iso: &str) -> Result<()> {
        #[cfg(feature = "backend-redfish")]
        {
            let url = virtual_media_action_url(node, self.slot(), "InsertMedia");
            self.post(node, &url, &insert_media_body(iso))
        }
        #[cfg(not(feature = "backend-redfish"))]
        {
            let _ = (node, iso);
            Err(unsupported())
        }
    }

    fn eject_media(&self, node: &BmcEndpoint) -> Result<()> {
        #[cfg(feature = "backend-redfish")]
        {
            let url = virtual_media_action_url(node, self.slot(), "EjectMedia");
            self.post(node, &url, &serde_json::json!({}))
        }
        #[cfg(not(feature = "backend-redfish"))]
        {
            let _ = node;
            Err(unsupported())
        }
    }

    fn set_boot_override(&self, node: &BmcEndpoint, target: BootTarget) -> Result<()> {
        #[cfg(feature = "backend-redfish")]
        {
            self.patch(node, &system_url(node), &boot_override_body(target))
        }
        #[cfg(not(feature = "backend-redfish"))]
        {
            let _ = (node, target);
            Err(unsupported())
        }
    }
}

impl Lifecycle for RedfishBoot {
    fn power_on(&self, machine: &Machine) -> Result<()> {
        let _ = machine;
        #[cfg(feature = "backend-redfish")]
        {
            self.reset(self.bound()?, "On")
        }
        #[cfg(not(feature = "backend-redfish"))]
        {
            Err(unsupported())
        }
    }

    fn power_off(&self, machine: &Machine) -> Result<()> {
        let _ = machine;
        #[cfg(feature = "backend-redfish")]
        {
            self.reset(self.bound()?, "ForceOff")
        }
        #[cfg(not(feature = "backend-redfish"))]
        {
            Err(unsupported())
        }
    }

    fn status(&self, machine: &Machine) -> Result<PowerState> {
        let _ = machine;
        #[cfg(feature = "backend-redfish")]
        {
            self.query_power(self.bound()?)
        }
        #[cfg(not(feature = "backend-redfish"))]
        {
            Err(unsupported())
        }
    }
}

/// The honest "feature not compiled in" error for the un-gated trait methods.
#[cfg(not(feature = "backend-redfish"))]
fn unsupported() -> Error {
    Error::Unsupported(
        "redfish backend needs the `backend-redfish` feature (pure-Rust ureq/rustls Redfish client)"
            .into(),
    )
}

// ---------------------------------------------------------------------------
// Request shaping — PURE: resource URLs + JSON action bodies. Fixture-tested.
//
// The URL and body vocabulary now lives in the shared [`wire`] module, because
// draupnir owns BOTH ends of this protocol (this client and, behind
// `redfish-server`, [`crate::redfish_server`]). The functions below are the
// client's thin `host` + `path` composition on top of it.
// ---------------------------------------------------------------------------

pub mod wire;

/// The trailing-slash-trimmed Redfish service base for `bmc`.
#[cfg(feature = "backend-redfish")]
fn base(bmc: &BmcEndpoint) -> &str {
    bmc.host.trim_end_matches('/')
}

/// The `ComputerSystem` resource URL: `{host}/redfish/v1/Systems/{system_id}`.
#[cfg(feature = "backend-redfish")]
pub(crate) fn system_url(bmc: &BmcEndpoint) -> String {
    format!("{}{}", base(bmc), wire::system_path(&bmc.system_id))
}

/// A `VirtualMedia` action URL, e.g.
/// `{host}/redfish/v1/Systems/{system_id}/VirtualMedia/{slot}/Actions/VirtualMedia.{action}`.
#[cfg(feature = "backend-redfish")]
pub(crate) fn virtual_media_action_url(bmc: &BmcEndpoint, slot: &str, action: &str) -> String {
    format!(
        "{}{}",
        base(bmc),
        wire::virtual_media_action_path(&bmc.system_id, slot, action)
    )
}

/// The `ComputerSystem.Reset` action URL.
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_url(bmc: &BmcEndpoint) -> String {
    format!("{}{}", base(bmc), wire::reset_path(&bmc.system_id))
}

/// The `VirtualMedia.InsertMedia` request body — mount `iso` read-only.
#[cfg(feature = "backend-redfish")]
pub(crate) fn insert_media_body(iso: &str) -> serde_json::Value {
    wire::insert_media_body(iso)
}

/// The one-time `Boot` override PATCH body for `target`.
#[cfg(feature = "backend-redfish")]
pub(crate) fn boot_override_body(target: BootTarget) -> serde_json::Value {
    wire::boot_override_body(target)
}

/// The `ComputerSystem.Reset` body for a Redfish `ResetType` (`On` / `ForceOff` / …).
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_body(reset_type: &str) -> serde_json::Value {
    wire::reset_body(reset_type)
}

// The `BootTarget` → `BootSourceOverrideTarget` mapping is `wire::target_str`; the
// client has no wrapper of its own, so there is exactly one place that decides what
// `Cd` is called on the wire.

// ---------------------------------------------------------------------------
// Transport — ureq/rustls (feature `backend-redfish`).
// ---------------------------------------------------------------------------

#[cfg(feature = "backend-redfish")]
impl RedfishBoot {
    /// Build a ureq agent honouring the configured TLS trust (pure rustls — no
    /// openssl/native-tls). Precedence, most-secure first:
    ///
    /// 1. **pinned cert** ([`pin_cert_pem`](Self::pin_cert_pem)) — verification stays
    ///    ON and the pinned cert is the *sole* trusted root (MITM-resistant). A
    ///    malformed pinned PEM is a hard error here rather than a silent fall-back.
    /// 2. **insecure** ([`insecure`](Self::insecure)) — all cert verification off
    ///    (last resort; a pinned cert overrides this).
    /// 3. default — the platform/webpki roots (a BMC with a CA-signed cert).
    fn agent(&self) -> Result<ureq::Agent> {
        use ureq::tls::TlsConfig;
        if let Some(pem) = &self.pinned_cert_pem {
            use ureq::tls::{Certificate, RootCerts};
            let cert = Certificate::from_pem(pem).map_err(|e| {
                Error::Backend(format!("redfish: invalid pinned BMC cert PEM: {e}"))
            })?;
            let tls = TlsConfig::builder()
                .root_certs(RootCerts::from([cert]))
                .build();
            Ok(ureq::config::Config::builder()
                .tls_config(tls)
                .build()
                .into())
        } else if self.insecure {
            let tls = TlsConfig::builder().disable_verification(true).build();
            Ok(ureq::config::Config::builder()
                .tls_config(tls)
                .build()
                .into())
        } else {
            Ok(ureq::Agent::new_with_defaults())
        }
    }

    /// The HTTP Basic `Authorization` header value for `bmc` + the drive-time secret.
    fn auth(&self, bmc: &BmcEndpoint) -> String {
        format!(
            "Basic {}",
            base64(format!("{}:{}", bmc.username, self.password).as_bytes())
        )
    }

    /// POST a Redfish action body to `url`.
    fn post(&self, bmc: &BmcEndpoint, url: &str, body: &serde_json::Value) -> Result<()> {
        self.agent()?
            .post(url)
            .header("Authorization", &self.auth(bmc))
            .send_json(body)
            .map_err(|e| Error::Backend(format!("redfish POST {url}: {e}")))?;
        Ok(())
    }

    /// PATCH a Redfish resource at `url`.
    fn patch(&self, bmc: &BmcEndpoint, url: &str, body: &serde_json::Value) -> Result<()> {
        self.agent()?
            .patch(url)
            .header("Authorization", &self.auth(bmc))
            .send_json(body)
            .map_err(|e| Error::Backend(format!("redfish PATCH {url}: {e}")))?;
        Ok(())
    }

    /// Drive a `ComputerSystem.Reset` with `reset_type` (`On`/`ForceOff`/…).
    fn reset(&self, bmc: &BmcEndpoint, reset_type: &str) -> Result<()> {
        self.post(bmc, &reset_url(bmc), &reset_body(reset_type))
    }

    /// GET the `ComputerSystem` and map its `PowerState` onto [`PowerState`].
    fn query_power(&self, bmc: &BmcEndpoint) -> Result<PowerState> {
        let url = system_url(bmc);
        let mut resp = self
            .agent()?
            .get(&url)
            .header("Authorization", &self.auth(bmc))
            .call()
            .map_err(|e| Error::Backend(format!("redfish GET {url}: {e}")))?;
        let v: serde_json::Value = resp
            .body_mut()
            .read_json()
            .map_err(|e| Error::Backend(format!("redfish decode {url}: {e}")))?;
        Ok(match v.get("PowerState").and_then(|p| p.as_str()) {
            Some("On") => PowerState::On,
            Some("Off") => PowerState::Off,
            _ => PowerState::Unknown,
        })
    }
}

/// Minimal std-only base64 for the Basic-auth header. The implementation lives in
/// [`wire::base64_encode`] alongside its inverse, because the SERVER has to decode
/// exactly what this encodes — an encoder here and a decoder there would be two
/// halves of one alphabet maintained in two places.
#[cfg(feature = "backend-redfish")]
fn base64(input: &[u8]) -> String {
    wire::base64_encode(input)
}

#[cfg(all(test, feature = "backend-redfish"))]
mod tests {
    use super::*;

    fn bmc() -> BmcEndpoint {
        BmcEndpoint {
            host: "https://bmc-42.dc.example/".into(), // trailing slash to prove trim
            username: "admin".into(),
            system_id: "System.Embedded.1".into(),
        }
    }

    #[test]
    fn system_and_reset_urls_are_well_formed() {
        let b = bmc();
        assert_eq!(
            system_url(&b),
            "https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1"
        );
        assert_eq!(
            reset_url(&b),
            "https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
        );
    }

    #[test]
    fn virtual_media_insert_url_targets_the_cd_slot() {
        assert_eq!(
            virtual_media_action_url(&bmc(), "CD", "InsertMedia"),
            "https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia"
        );
    }

    #[test]
    fn insert_media_body_mounts_the_iso_read_only() {
        let body = insert_media_body("http://srv/installer.iso");
        assert_eq!(body["Image"], "http://srv/installer.iso");
        assert_eq!(body["Inserted"], true);
        assert_eq!(body["WriteProtected"], true);
    }

    #[test]
    fn boot_override_body_is_one_time_to_cd() {
        let body = boot_override_body(BootTarget::Cd);
        assert_eq!(body["Boot"]["BootSourceOverrideEnabled"], "Once");
        assert_eq!(body["Boot"]["BootSourceOverrideTarget"], "Cd");
    }

    #[test]
    fn boot_targets_map_to_redfish_tokens() {
        assert_eq!(wire::target_str(BootTarget::Cd), "Cd");
        assert_eq!(wire::target_str(BootTarget::Pxe), "Pxe");
        assert_eq!(wire::target_str(BootTarget::Hdd), "Hdd");
        assert_eq!(wire::target_str(BootTarget::BiosSetup), "BiosSetup");
    }

    #[test]
    fn reset_body_carries_the_reset_type() {
        assert_eq!(reset_body("On")["ResetType"], "On");
        assert_eq!(reset_body("ForceOff")["ResetType"], "ForceOff");
    }

    #[test]
    fn base64_matches_known_vectors() {
        assert_eq!(base64(b""), "");
        assert_eq!(base64(b"f"), "Zg==");
        assert_eq!(base64(b"fo"), "Zm8=");
        assert_eq!(base64(b"foo"), "Zm9v");
        assert_eq!(base64(b"admin:secret"), "YWRtaW46c2VjcmV0");
    }

    #[test]
    fn auth_header_is_basic_base64_of_user_colon_password() {
        let rf = RedfishBoot::new().with_password("secret");
        assert_eq!(rf.auth(&bmc()), "Basic YWRtaW46c2VjcmV0");
    }

    // A well-formed self-signed X509 cert (PEM) — only its *shape* matters here:
    // `pin_cert_pem` parses it into a trusted root; no network/validity check runs.
    const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
MIIDHTCCAgWgAwIBAgIUHkyHfhWhNsGjKB2/NlJVedpan9QwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTYm1jLXRlc3QuZGMuZXhhbXBsZTAeFw0yNjA3MTQxNjAw
MTdaFw0zNjA3MTExNjAwMTdaMB4xHDAaBgNVBAMME2JtYy10ZXN0LmRjLmV4YW1w
bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD4nFGHKjOCUyDnJlkR
UHA2JCReXmGhLzLRvlj70Mcs48A5KRVsCPBfq/VlMVv92iSscGQ5Rnn2dpMLbIu0
F+T6zLl1+mAO8HLv+1BZYRhcHyA6slDKvg6K8G3gUNS9poQvzoFZEptacdn/dLSw
LSuEhTLdxHRzdWWxlIbmDTa2OZwhwjGD9KrKyT0ddg386e8KwcmuT+Yq3tbs7ceq
rblHgvd5k9l5e/fwFLpj1XtHb34XILR838Ba4MvL4Tmfjr2OdmqFCkNOOODew5PF
6MICrj5U92Xtq+WJQEIa2Ug8+EgAxOVrafdM84olvzt9+yAJj8YJVhvT8wyc36Sp
o6WZAgMBAAGjUzBRMB0GA1UdDgQWBBSIqmembS5QXMrP67M0oBYVvqdGCTAfBgNV
HSMEGDAWgBSIqmembS5QXMrP67M0oBYVvqdGCTAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQCWy1V7aJ+E6E9XjozsqTw2jZ+xCjZ1iUyYmHc2wdOP
RHKcqfSwLwJRyFG5+z8StclGRFL6cR1B+WaZ2ffmUbrQZQBddeFhGQpqzEJ6ba6t
cu1sjQKU7d56MqgyJQ8Kqv09jDQAv6cct1wwjGUm/XqzssPR52diL027wCP9zqAC
Qm7HuDq6y2KiOwV23HB6Csx/DTvzco/q2RGFFod48Ey0SUPk6BTMl0EtipTj92w2
w5sUfZNCMzv/Fq9wEnU/UV6qE0n3HEsfbdqwyWJTkkb/orYoeqz6LXV53B2Z0BgD
VrHskWfBU45Hvxz5BWLMeDtGjgiRNF/WcfL6Ix4Aa5NC
-----END CERTIFICATE-----";

    // SECURITY (LENS-4): the Debug impl must NEVER render the BMC secret. RED if the
    // derived Debug (which prints `password` verbatim) is ever restored.
    #[test]
    fn debug_redacts_the_bmc_password() {
        let rf = RedfishBoot::new().with_password("hunter2-super-secret");
        let dbg = format!("{rf:?}");
        assert!(
            !dbg.contains("hunter2-super-secret"),
            "Debug leaked the BMC password: {dbg}"
        );
        assert!(
            dbg.contains("<redacted>"),
            "Debug should mark the set password redacted: {dbg}"
        );
        // An unset password reads as <unset>, still never the empty-string secret.
        assert!(format!("{:?}", RedfishBoot::new()).contains("<unset>"));
    }

    // SECURITY: a pinned cert's raw bytes must not appear in Debug either.
    #[test]
    fn debug_does_not_dump_the_pinned_cert_bytes() {
        let rf = RedfishBoot::new().pin_cert_pem(TEST_CERT_PEM.as_bytes().to_vec());
        let dbg = format!("{rf:?}");
        assert!(
            dbg.contains("<pinned>"),
            "Debug should mark the pinned cert present: {dbg}"
        );
        assert!(
            !dbg.contains("BEGIN CERTIFICATE"),
            "Debug dumped raw pinned-cert bytes: {dbg}"
        );
    }

    // A valid pinned cert builds a real agent (verification stays ON, cert trusted).
    #[test]
    fn pinned_cert_builds_an_agent() {
        let rf = RedfishBoot::new().pin_cert_pem(TEST_CERT_PEM.as_bytes().to_vec());
        assert!(
            rf.agent().is_ok(),
            "a well-formed pinned PEM should build an agent"
        );
    }

    // RED-when-broken: a bogus/mismatched pinned PEM must be REJECTED, not silently
    // ignored. If `agent()` ever stops actually parsing the pinned cert (the raw
    // salvage no-op), this passes garbage and the test fails.
    #[test]
    fn a_malformed_pinned_cert_is_rejected() {
        let rf = RedfishBoot::new().pin_cert_pem(
            b"-----BEGIN CERTIFICATE-----\nnot-a-real-cert\n-----END CERTIFICATE-----".to_vec(),
        );
        assert!(
            matches!(rf.agent(), Err(Error::Backend(_))),
            "a malformed pinned cert must be rejected"
        );
        // Empty PEM: no cert found → also rejected.
        assert!(RedfishBoot::new().pin_cert_pem(Vec::new()).agent().is_err());
    }

    // Pinning takes precedence over insecure (the secure path wins the escape hatch).
    #[test]
    fn pinned_cert_takes_precedence_over_insecure() {
        // insecure alone builds fine...
        assert!(RedfishBoot::new().insecure(true).agent().is_ok());
        // ...but with a pin ALSO set, the pin path runs — so a bad pin still errors
        // even though `insecure(true)` would otherwise have skipped verification.
        let rf = RedfishBoot::new()
            .insecure(true)
            .pin_cert_pem(b"garbage".to_vec());
        assert!(
            rf.agent().is_err(),
            "a pinned cert must take precedence over insecure()"
        );
    }

    #[test]
    fn lifecycle_without_a_bound_node_is_an_honest_error() {
        let rf = RedfishBoot::new().with_password("x");
        let m = Machine {
            id: "System.Embedded.1".into(),
            spec_name: "n".into(),
            kind: crate::Kind::BareMetal,
            power: PowerState::Unknown,
        };
        assert!(matches!(rf.power_on(&m), Err(Error::Backend(_))));
    }

    // ── THE FLEET WIRE-SHAPE PROOF (appliance brief §4) ──────────────────────
    // "A per-site installer ISO built by the Build Thing → N machines booted
    // from it": plan_fleet() fans ONE BootSpec into N members, the caller
    // binds each member to ITS OWN BMC (target() reads spec.bmc per call —
    // plan_fleet clones, it does not bind), and for every member the THREE
    // requests the backend would send are exactly the fixtures the seam above
    // already proves one at a time: InsertMedia mounting THE per-site ISO
    // read-only, a one-time boot override to Cd, and a power-on reset. No BMC
    // and no Redfish HTTP mock is reachable on this box (measured 2026-08-13),
    // so the live HTTP exchange is deliberately NOT faked here — this pins the
    // fleet-level composition of the request shapes, and the doc
    // (.nornir/iso-to-fleet.md) states the wall verbatim.
    #[cfg(feature = "backend-redfish")]
    #[test]
    fn a_fleet_of_three_shapes_the_same_burn_against_three_distinct_bmcs() {
        use crate::{plan_fleet, BootSpec};

        // The per-site ISO the Build Thing mastered — the BMC PULLS a URL
        // (Redfish virtual media is a network fetch, never a local path), so
        // the operator serves .iso-out/ over http(s) and THIS string is what
        // every BMC in the fleet mounts.
        let iso_url = "https://depot.site7.example/holger-installer.iso";
        let spec = BootSpec::redfish_iso("site7-holger", iso_url, bmc());

        let mut members = plan_fleet(&spec, 3);
        assert_eq!(members.len(), 3);
        // plan_fleet CLONES — every member starts with the template's BMC.
        // Distinct machines need distinct BMCs, and that binding is the
        // CALLER's move, made explicit here:
        for (i, m) in members.iter_mut().enumerate() {
            m.bmc = Some(BmcEndpoint {
                host: format!("https://bmc-{}.dc.example", i + 1),
                username: "admin".into(),
                system_id: "System.Embedded.1".into(),
            });
        }

        let rf = RedfishBoot::new().with_password("per-site-secret");
        let mut seen_hosts = std::collections::BTreeSet::new();
        for (i, member) in members.iter().enumerate() {
            // Names stay distinct (the fan-out's own contract).
            assert_eq!(member.name, format!("site7-holger-{}", i + 1));
            // target() resolves THIS member's BMC + the shared ISO.
            let (bmc, iso) = rf.target(member).expect("a bound member resolves");
            assert_eq!(iso, iso_url, "every machine burns the SAME per-site ISO");
            assert!(seen_hosts.insert(bmc.host.clone()), "each member hits its OWN BMC");
            // The three requests, byte-shaped as the seam's fixtures prove:
            let insert = virtual_media_action_url(bmc, DEFAULT_MEDIA_ID, "InsertMedia");
            assert_eq!(
                insert,
                format!(
                    "https://bmc-{}.dc.example/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia",
                    i + 1
                )
            );
            let body = insert_media_body(iso);
            assert_eq!(body["Image"], iso_url);
            assert_eq!(body["WriteProtected"], true, "the install medium is read-only");
            let ovr = boot_override_body(BootTarget::Cd);
            assert_eq!(ovr["Boot"]["BootSourceOverrideEnabled"], "Once");
            assert_eq!(ovr["Boot"]["BootSourceOverrideTarget"], "Cd");
            assert_eq!(reset_body("On")["ResetType"], "On");
        }
        assert_eq!(seen_hosts.len(), 3, "three machines, three BMCs");
    }

    /// RED: a fanned member whose BMC binding was FORGOTTEN refuses by name —
    /// the fleet path must never quietly burn machine 3's ISO into machine 1.
    #[cfg(feature = "backend-redfish")]
    #[test]
    fn a_member_without_its_own_bmc_refuses_instead_of_borrowing_one() {
        use crate::{plan_fleet, BootSpec};
        let mut spec = BootSpec::redfish_iso("site7-holger", "https://depot/holger.iso", bmc());
        spec.bmc = None; // the template never carried a binding
        let members = plan_fleet(&spec, 2);
        let rf = RedfishBoot::new().with_password("x");
        for m in &members {
            let err = rf.target(m).unwrap_err();
            assert!(
                err.to_string().contains("BMC"),
                "the refusal names the missing binding: {err}"
            );
        }
    }
}