//! AIRGAP4 — per-machine PKI.
//!
//! Design (`.nornir/airgap-install.md` §2, `install-system.md` Phase 2): an
//! air-gapped machine needs its own PKI. We support three sources via
//! [`PkiSource`]:
//!
//! - [`PkiSource::SelfGenerated`] — **implemented, pure-Rust** (no network, no
//!   Vault): a real **ed25519** machine CA keypair + per-service leaf identities,
//!   issued as genuine **X.509** certificates via [`rcgen`]. Because the target
//!   has no NTP, validity is monotonic-only (we record a wide window but
//!   verification is signature-based, not clock-based — the airgap constraint
//!   table).
//! - [`PkiSource::ExistingVault`] / [`PkiSource::TempVault`] — **trait seams**
//!   ([`VaultClient`]) with explicit TODOs; an air-gapped Vault integration is a
//!   large external dependency and is deliberately not faked.
//!
//! The real cryptographic object is [`MachineCa`]: it holds a genuine ed25519
//! signing key and a self-signed X.509 CA certificate, can **issue** leaf certs
//! ([`LeafCert`]) — real X.509 with SANs, signed by the CA — and the leaf
//! **verifies** against the CA's public key and **loads into a rustls config**,
//! which is exactly what the test asserts (a real cert, not a placeholder).

use anyhow::Result;
use serde::{Deserialize, Serialize};

/// Where the PKI comes from (design §2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PkiSource {
    /// Call an existing Vault at this address. SEAM — see [`VaultClient`].
    ExistingVault(String),
    /// Generate the PKI ourselves in pure Rust (ed25519). IMPLEMENTED.
    SelfGenerated,
    /// Spin a temporary Vault, generate, tear down. SEAM — see [`VaultClient`].
    TempVault,
}

/// A leaf identity issued by a [`MachineCa`]: a genuine **X.509** certificate
/// (PEM + DER) for the subject, signed by the CA and carrying the requested SANs.
/// Self-contained and verifiable offline; the DER/PEM load directly into rustls.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeafCert {
    /// Subject common name (e.g. `holger.<hostname>.x14.local`).
    pub subject_cn: String,
    /// Subject alternative names (service DNS names / IPs).
    pub sans: Vec<String>,
    /// Issuing CA common name.
    pub issuer_cn: String,
    /// Validity, seconds since epoch (wide window — no NTP on target).
    pub not_before: i64,
    pub not_after: i64,
    /// The issued X.509 certificate, PEM-encoded (`-----BEGIN CERTIFICATE-----`).
    pub cert_pem: String,
    /// The same X.509 certificate, DER-encoded (parses straight into rustls /
    /// x509-parser without a PEM round-trip).
    pub cert_der: Vec<u8>,
}

#[cfg(feature = "pki")]
mod real {
    use super::*;
    use ed25519_dalek::{SigningKey, VerifyingKey};
    use rcgen::{
        BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
        IsCa, KeyPair, KeyUsagePurpose, SanType, PKCS_ED25519,
    };
    use rustls_pki_types::PrivatePkcs8KeyDer;
    use time::OffsetDateTime;

    /// A real ed25519 machine CA — the root of the per-machine PKI. Holds the
    /// dalek signing key (identity / [`verifying_key`](Self::verifying_key)) and
    /// the matching self-signed X.509 CA certificate used to sign leaves.
    pub struct MachineCa {
        common_name: String,
        signing: SigningKey,
        /// The CA's rcgen keypair (same ed25519 key as `signing`) + self-signed
        /// X.509 cert — used to sign issued leaves.
        ca_key: KeyPair,
        ca_cert: rcgen::Certificate,
        /// Wide validity window (no NTP on target → monotonic-only checks).
        not_before: i64,
        not_after: i64,
    }

    impl MachineCa {
        /// Generate a fresh machine CA for `hostname` (CN = `<hostname>.x14.local`).
        /// 10-year window (the airgap "no NTP → wide validity" guidance).
        pub fn generate(hostname: &str) -> Result<Self> {
            let now = chrono::Utc::now().timestamp();
            Self::build(
                format!("{hostname}.x14.local"),
                os_seed()?,
                now,
                now + 10 * 365 * 24 * 3600,
            )
        }

        /// Deterministic generation from a 32-byte seed — used by tests so the
        /// assertion is reproducible (no RNG flakiness), still a real keypair.
        pub fn from_seed(hostname: &str, seed: [u8; 32]) -> Self {
            Self::build(
                format!("{hostname}.x14.local"),
                seed,
                0,
                10 * 365 * 24 * 3600,
            )
            .expect("deterministic CA from fixed seed is infallible")
        }

        /// Construct the CA: derive the dalek key from `seed`, rebuild the same
        /// ed25519 key as an rcgen [`KeyPair`], and self-sign an X.509 CA cert.
        fn build(common_name: String, seed: [u8; 32], not_before: i64, not_after: i64) -> Result<Self> {
            let signing = SigningKey::from_bytes(&seed);
            let pkcs8 = pkcs8_v1_ed25519(&seed);
            let ca_key =
                KeyPair::from_pkcs8_der_and_sign_algo(&PrivatePkcs8KeyDer::from(pkcs8.as_slice()), &PKCS_ED25519)
                    .map_err(|e| anyhow::anyhow!("rcgen CA keypair from seed: {e}"))?;

            let mut params = CertificateParams::new(Vec::<String>::new())
                .map_err(|e| anyhow::anyhow!("rcgen CA params: {e}"))?;
            params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
            params.distinguished_name = DistinguishedName::new();
            params.distinguished_name.push(DnType::CommonName, common_name.clone());
            params.key_usages.push(KeyUsagePurpose::DigitalSignature);
            params.key_usages.push(KeyUsagePurpose::KeyCertSign);
            params.key_usages.push(KeyUsagePurpose::CrlSign);
            params.not_before = offset(not_before)?;
            params.not_after = offset(not_after)?;
            let ca_cert = params
                .self_signed(&ca_key)
                .map_err(|e| anyhow::anyhow!("rcgen self-sign CA: {e}"))?;

            Ok(MachineCa {
                common_name,
                signing,
                ca_key,
                ca_cert,
                not_before,
                not_after,
            })
        }

        /// The CA's verifying (public) key.
        pub fn verifying_key(&self) -> VerifyingKey {
            self.signing.verifying_key()
        }

        /// CA common name.
        pub fn common_name(&self) -> &str {
            &self.common_name
        }

        /// The self-signed X.509 CA certificate, PEM-encoded (the chain root that
        /// a verifier trusts to validate issued leaves).
        pub fn ca_cert_pem(&self) -> String {
            self.ca_cert.pem()
        }

        /// Issue a leaf identity for `service_cn` with `sans`, signed by this CA.
        /// Generates a fresh subject ed25519 keypair, mints a real X.509 leaf cert
        /// (ServerAuth EKU + SANs) signed by the CA, and returns the public
        /// [`LeafCert`] plus the subject's PKCS#8 private key (PEM) so the
        /// installer can write `<svc>.key`.
        pub fn issue_leaf(&self, service_cn: &str, sans: &[String]) -> Result<(LeafCert, String)> {
            let leaf_key = KeyPair::generate_for(&PKCS_ED25519)
                .map_err(|e| anyhow::anyhow!("rcgen leaf keypair: {e}"))?;

            let mut params = CertificateParams::new(Vec::<String>::new())
                .map_err(|e| anyhow::anyhow!("rcgen leaf params: {e}"))?;
            params.distinguished_name = DistinguishedName::new();
            params.distinguished_name.push(DnType::CommonName, service_cn);
            for san in sans {
                params.subject_alt_names.push(san_from_str(san)?);
            }
            params.use_authority_key_identifier_extension = true;
            params.key_usages.push(KeyUsagePurpose::DigitalSignature);
            params
                .extended_key_usages
                .push(ExtendedKeyUsagePurpose::ServerAuth);
            params.not_before = offset(self.not_before)?;
            params.not_after = offset(self.not_after)?;

            let cert = params
                .signed_by(&leaf_key, &self.ca_cert, &self.ca_key)
                .map_err(|e| anyhow::anyhow!("rcgen sign leaf under CA: {e}"))?;

            let leaf = LeafCert {
                subject_cn: service_cn.to_string(),
                sans: sans.to_vec(),
                issuer_cn: self.common_name.clone(),
                not_before: self.not_before,
                not_after: self.not_after,
                cert_pem: cert.pem(),
                cert_der: cert.der().to_vec(),
            };
            Ok((leaf, leaf_key.serialize_pem()))
        }
    }

    /// 32 bytes from the OS CSPRNG (getrandom) — the keypair seed.
    fn os_seed() -> Result<[u8; 32]> {
        let mut seed = [0u8; 32];
        getrandom::getrandom(&mut seed)
            .map_err(|e| anyhow::anyhow!("getrandom for PKI keypair seed: {e}"))?;
        Ok(seed)
    }

    /// Convert epoch seconds into the `time::OffsetDateTime` rcgen validity wants.
    fn offset(secs: i64) -> Result<OffsetDateTime> {
        OffsetDateTime::from_unix_timestamp(secs)
            .map_err(|e| anyhow::anyhow!("invalid PKI validity timestamp {secs}: {e}"))
    }

    /// A SAN string → rcgen [`SanType`]: an IP literal becomes `IpAddress`,
    /// anything else a `DnsName`.
    fn san_from_str(s: &str) -> Result<SanType> {
        if let Ok(ip) = s.parse::<std::net::IpAddr>() {
            Ok(SanType::IpAddress(ip))
        } else {
            Ok(SanType::DnsName(
                s.try_into()
                    .map_err(|e| anyhow::anyhow!("invalid DNS SAN {s:?}: {e}"))?,
            ))
        }
    }

    /// Wrap a 32-byte ed25519 seed in a PKCS#8 v1 (`OneAsymmetricKey`) DER blob —
    /// the form rcgen/ring's `from_pkcs8_maybe_unchecked` accepts. The 16-byte
    /// prefix is the fixed Ed25519 PKCS#8 header (RFC 8410).
    fn pkcs8_v1_ed25519(seed: &[u8; 32]) -> Vec<u8> {
        let mut v = Vec::with_capacity(48);
        v.extend_from_slice(&[
            0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
            0x04, 0x20,
        ]);
        v.extend_from_slice(seed);
        v
    }

    /// Wrap a 32-byte ed25519 public key in a `SubjectPublicKeyInfo` DER blob
    /// (RFC 8410) so x509-parser can use it as the issuer key for verification.
    fn spki_ed25519(pubkey: &[u8; 32]) -> Vec<u8> {
        let mut v = Vec::with_capacity(44);
        v.extend_from_slice(&[
            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
        ]);
        v.extend_from_slice(pubkey);
        v
    }

    /// Verify a [`LeafCert`] against an issuing CA verifying key — the real
    /// cryptographic check the test asserts on: parse the issued X.509 and verify
    /// its signature with `ca`'s ed25519 public key.
    pub fn verify_leaf(cert: &LeafCert, ca: &VerifyingKey) -> bool {
        use x509_parser::prelude::{FromDer, SubjectPublicKeyInfo, X509Certificate};
        let leaf = match X509Certificate::from_der(&cert.cert_der) {
            Ok((_, c)) => c,
            Err(_) => return false,
        };
        let spki_der = spki_ed25519(ca.as_bytes());
        let spki = match SubjectPublicKeyInfo::from_der(&spki_der) {
            Ok((_, s)) => s,
            Err(_) => return false,
        };
        leaf.verify_signature(Some(&spki)).is_ok()
    }
}

#[cfg(feature = "pki")]
pub use real::{verify_leaf, MachineCa};

/// A leaf cert **issued by Vault's PKI engine** — a real X.509 chain returned by
/// `POST /v1/pki/issue/<role>` (PEM `certificate` + `private_key` + `issuing_ca`),
/// plus the parsed subject CN / SANs / serial. Distinct from [`LeafCert`] (our
/// pure-Rust ed25519 self-signed shape): Vault issues RSA/ECDSA X.509, so we keep
/// the PEM verbatim rather than forcing it into the ed25519 struct.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VaultIssuedCert {
    /// Subject common name requested.
    pub subject_cn: String,
    /// Subject alternative names requested.
    pub sans: Vec<String>,
    /// PEM-encoded leaf certificate (`-----BEGIN CERTIFICATE-----`).
    pub certificate_pem: String,
    /// PEM-encoded private key for the leaf.
    pub private_key_pem: String,
    /// PEM-encoded issuing CA certificate (the chain root for this leaf).
    pub issuing_ca_pem: String,
    /// Vault's serial number for the issued cert (colon-hex).
    pub serial_number: String,
}

impl VaultIssuedCert {
    /// Whether the PEM material looks like a real, parseable cert + key (the
    /// receiver-side sanity check; full chain verification is left to the TLS
    /// stack at connect time).
    pub fn is_well_formed(&self) -> bool {
        self.certificate_pem.contains("-----BEGIN CERTIFICATE-----")
            && self.certificate_pem.contains("-----END CERTIFICATE-----")
            && self.private_key_pem.contains("-----BEGIN")
            && !self.serial_number.is_empty()
    }
}

/// An existing/temporary Vault PKI backend. With the `vault` feature on,
/// [`HttpVaultClient`] makes the **real** `POST /v1/pki/issue/<role>` call.
pub trait VaultClient {
    /// Issue a leaf cert for `cn`/`sans` from the Vault PKI engine.
    fn issue(&self, cn: &str, sans: &[String]) -> Result<VaultIssuedCert>;
}

/// The Vault PKI HTTP integration (feature `vault`).
#[cfg(feature = "vault")]
pub mod vault {
    use super::*;
    use std::time::Duration;

    /// A client for an **existing** Vault, authenticated by a token. Talks to the
    /// PKI secrets engine mounted at `pki` (override via [`HttpVaultClient::mount`])
    /// and issues leaves against `role`.
    pub struct HttpVaultClient {
        addr: String,
        token: String,
        mount: String,
        role: String,
        ttl: Option<String>,
        http: reqwest::blocking::Client,
    }

    impl HttpVaultClient {
        /// Connect to an existing Vault at `addr` (e.g. `https://vault:8200`) with
        /// `token` (the `X-Vault-Token` header), issuing against PKI `role`.
        pub fn new(addr: impl Into<String>, token: impl Into<String>, role: impl Into<String>) -> Result<Self> {
            let http = reqwest::blocking::Client::builder()
                .timeout(Duration::from_secs(30))
                .build()
                .map_err(|e| anyhow::anyhow!("build Vault HTTP client: {e}"))?;
            Ok(HttpVaultClient {
                addr: addr.into().trim_end_matches('/').to_string(),
                token: token.into(),
                mount: "pki".to_string(),
                role: role.into(),
                ttl: None,
                http,
            })
        }

        /// Override the PKI mount path (default `pki`).
        pub fn mount(mut self, mount: impl Into<String>) -> Self {
            self.mount = mount.into();
            self
        }

        /// Request a specific TTL for the issued cert (e.g. `"720h"`).
        pub fn ttl(mut self, ttl: impl Into<String>) -> Self {
            self.ttl = Some(ttl.into());
            self
        }

        /// The issue endpoint URL: `<addr>/v1/<mount>/issue/<role>`.
        pub fn issue_url(&self) -> String {
            format!("{}/v1/{}/issue/{}", self.addr, self.mount, self.role)
        }

        /// The JSON request body Vault's PKI `issue` endpoint expects.
        pub fn issue_request_body(cn: &str, sans: &[String], ttl: Option<&str>) -> serde_json::Value {
            let mut body = serde_json::json!({
                "common_name": cn,
                "format": "pem",
            });
            if !sans.is_empty() {
                body["alt_names"] = serde_json::Value::String(sans.join(","));
            }
            if let Some(ttl) = ttl {
                body["ttl"] = serde_json::Value::String(ttl.to_string());
            }
            body
        }
    }

    impl VaultClient for HttpVaultClient {
        fn issue(&self, cn: &str, sans: &[String]) -> Result<VaultIssuedCert> {
            let body = Self::issue_request_body(cn, sans, self.ttl.as_deref());
            let resp = self
                .http
                .post(self.issue_url())
                .header("X-Vault-Token", &self.token)
                .json(&body)
                .send()
                .map_err(|e| anyhow::anyhow!("Vault issue POST {}: {e}", self.issue_url()))?;
            let status = resp.status();
            let text = resp
                .text()
                .map_err(|e| anyhow::anyhow!("read Vault issue response body: {e}"))?;
            if !status.is_success() {
                anyhow::bail!("Vault issue returned HTTP {status}: {text}");
            }
            parse_issue_response(cn, sans, &text)
        }
    }

    /// Parse Vault's `POST /v1/pki/issue/<role>` JSON response into a
    /// [`VaultIssuedCert`]. Pure → unit-testable against a captured response shape.
    pub fn parse_issue_response(cn: &str, sans: &[String], json: &str) -> Result<VaultIssuedCert> {
        let v: serde_json::Value = serde_json::from_str(json)
            .map_err(|e| anyhow::anyhow!("parse Vault issue JSON: {e}"))?;
        let data = v
            .get("data")
            .ok_or_else(|| anyhow::anyhow!("Vault issue response missing `data`"))?;
        let get = |k: &str| -> Result<String> {
            data.get(k)
                .and_then(|x| x.as_str())
                .map(str::to_string)
                .ok_or_else(|| anyhow::anyhow!("Vault issue response missing `data.{k}`"))
        };
        let cert = VaultIssuedCert {
            subject_cn: cn.to_string(),
            sans: sans.to_vec(),
            certificate_pem: get("certificate")?,
            private_key_pem: get("private_key")?,
            issuing_ca_pem: get("issuing_ca")?,
            serial_number: get("serial_number")?,
        };
        if !cert.is_well_formed() {
            anyhow::bail!("Vault issued a malformed cert (PEM/serial check failed)");
        }
        Ok(cert)
    }

    /// A **temporary, dev-mode Vault** spun up on the build/staging host: launch
    /// `vault server -dev`, enable + configure the PKI engine, then issue from it.
    /// This is the `TempVault` path — it needs the `vault` binary present (it is a
    /// staging-host tool, not an airgap-target tool), so it errors honestly when
    /// the binary is absent rather than faking a Vault.
    pub struct TempVaultClient {
        child: std::process::Child,
        inner: HttpVaultClient,
    }

    impl TempVaultClient {
        /// Spawn `vault server -dev` with a fixed root token, configure a PKI
        /// engine + role, and return a client bound to it. Tears the server down
        /// on [`Drop`].
        pub fn spawn(role: &str) -> Result<Self> {
            // `vault` is a staging-host convenience; absent on an airgap target.
            let present = std::process::Command::new("vault")
                .arg("--version")
                .output()
                .map(|o| o.status.success())
                .unwrap_or(false);
            if !present {
                anyhow::bail!(
                    "TempVault requires the `vault` binary (staging-host tool); not found in PATH"
                );
            }
            let token = "nornir-temp-root";
            let addr = "http://127.0.0.1:8200";
            let child = std::process::Command::new("vault")
                .args([
                    "server",
                    "-dev",
                    "-dev-root-token-id",
                    token,
                    "-dev-listen-address",
                    "127.0.0.1:8200",
                ])
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .spawn()
                .map_err(|e| anyhow::anyhow!("spawn `vault server -dev`: {e}"))?;

            // Give dev-mode Vault a moment to bind + unseal, then enable PKI.
            std::thread::sleep(Duration::from_millis(800));
            let http = reqwest::blocking::Client::builder()
                .timeout(Duration::from_secs(15))
                .build()?;
            // Mount the PKI engine.
            http.post(format!("{addr}/v1/sys/mounts/pki"))
                .header("X-Vault-Token", token)
                .json(&serde_json::json!({ "type": "pki" }))
                .send()
                .map_err(|e| anyhow::anyhow!("enable PKI engine on temp Vault: {e}"))?;
            // Generate an internal root CA.
            http.post(format!("{addr}/v1/pki/root/generate/internal"))
                .header("X-Vault-Token", token)
                .json(&serde_json::json!({ "common_name": "nornir-temp-ca", "ttl": "87600h" }))
                .send()
                .map_err(|e| anyhow::anyhow!("generate temp Vault root CA: {e}"))?;
            // Create the issuing role (allow any CN so the installer can pick).
            http.post(format!("{addr}/v1/pki/roles/{role}"))
                .header("X-Vault-Token", token)
                .json(&serde_json::json!({
                    "allow_any_name": true, "max_ttl": "8760h", "key_type": "rsa", "key_bits": 2048
                }))
                .send()
                .map_err(|e| anyhow::anyhow!("create temp Vault PKI role: {e}"))?;

            let inner = HttpVaultClient::new(addr, token, role)?;
            Ok(TempVaultClient { child, inner })
        }
    }

    impl VaultClient for TempVaultClient {
        fn issue(&self, cn: &str, sans: &[String]) -> Result<VaultIssuedCert> {
            self.inner.issue(cn, sans)
        }
    }

    impl Drop for TempVaultClient {
        fn drop(&mut self) {
            let _ = self.child.kill();
            let _ = self.child.wait();
        }
    }
}

#[cfg(feature = "vault")]
pub use vault::{HttpVaultClient, TempVaultClient};

/// Materialize the PKI per the chosen source.
///
/// `SelfGenerated` is fully implemented (pure-Rust ed25519). The Vault variants
/// require a [`VaultClient`] impl — passing `None` for them is an explicit,
/// honest error rather than a silent stub.
pub fn provision(src: &PkiSource, vault: Option<&dyn VaultClient>) -> Result<()> {
    match src {
        PkiSource::SelfGenerated => {
            #[cfg(feature = "pki")]
            {
                // Smoke the real path: generate a CA + a leaf so a caller without
                // a service list still proves the engine works.
                let ca = MachineCa::generate(&hostname())?;
                let _ = ca.issue_leaf("default", &[])?;
                Ok(())
            }
            #[cfg(not(feature = "pki"))]
            {
                anyhow::bail!("SelfGenerated PKI requires the `pki` cargo feature")
            }
        }
        // ExistingVault / TempVault: with a `VaultClient` we make the real
        // `POST /v1/pki/issue/<role>` call (see [`vault::HttpVaultClient`]).
        // Without one, this is an honest error rather than a silent fake.
        PkiSource::ExistingVault(_) | PkiSource::TempVault => match vault {
            Some(client) => {
                // Smoke the real path: issue a default leaf so a caller without a
                // service list still proves the engine is reachable + issuing.
                let cert = client.issue(&format!("default.{}", hostname()), &[])?;
                anyhow::ensure!(cert.is_well_formed(), "Vault issued a malformed cert");
                Ok(())
            }
            None => anyhow::bail!(
                "Vault PKI source needs a VaultClient impl — \
                 use `HttpVaultClient::new(addr, token, role)` (feature `vault`)"
            ),
        },
    }
}

fn hostname() -> String {
    std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
}

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

    /// LAW 1 (inject-assert): SelfGenerated PKI produces a REAL X.509 cert — it
    /// parses as valid X.509 (right subject + SANs), its signature **verifies**
    /// against the CA's ed25519 key (a different CA is rejected), and crucially it
    /// **loads into a real rustls `ServerConfig`** — the whole point of moving off
    /// the bespoke non-X.509 shape.
    #[test]
    fn self_generated_leaf_is_real_x509_and_loads_into_rustls() {
        use x509_parser::prelude::*;

        let ca = MachineCa::from_seed("oden", [7u8; 32]);
        let (cert, key_pem) = ca
            .issue_leaf(
                "holger.oden.x14.local",
                &["holger.oden.x14.local".into(), "10.0.0.5".into()],
            )
            .unwrap();

        assert_eq!(cert.issuer_cn, "oden.x14.local");
        assert_eq!(cert.sans, vec!["holger.oden.x14.local", "10.0.0.5"]);
        assert!(cert.not_after > cert.not_before, "wide validity window");
        assert!(cert.cert_pem.contains("-----BEGIN CERTIFICATE-----"));
        assert!(key_pem.contains("-----BEGIN PRIVATE KEY-----"));

        // Parses as genuine X.509 with the requested subject + SANs.
        let (_, x509) = X509Certificate::from_der(&cert.cert_der).expect("valid X.509 DER");
        assert!(x509.subject().to_string().contains("holger.oden.x14.local"));
        let san = x509
            .subject_alternative_name()
            .expect("SAN ext parses")
            .expect("leaf has a SAN extension");
        assert!(
            san.value
                .general_names
                .iter()
                .any(|g| matches!(g, GeneralName::DNSName("holger.oden.x14.local"))),
            "DNS SAN present"
        );
        assert!(
            san.value
                .general_names
                .iter()
                .any(|g| matches!(g, GeneralName::IPAddress(_))),
            "IP SAN present"
        );

        // The leaf's signature VERIFIES against the CA — a real chain, not a stub.
        assert!(verify_leaf(&cert, &ca.verifying_key()), "leaf verifies under CA");
        let other = MachineCa::from_seed("loki", [9u8; 32]);
        assert!(!verify_leaf(&cert, &other.verifying_key()), "wrong CA rejected");

        // GOLD: rustls actually loads the rcgen-issued leaf cert + key.
        let certs = rustls_pemfile::certs(&mut cert.cert_pem.as_bytes())
            .collect::<std::result::Result<Vec<_>, _>>()
            .expect("parse leaf cert PEM");
        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
            .expect("read key PEM")
            .expect("leaf key present");
        let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider());
        rustls::ServerConfig::builder_with_provider(provider)
            .with_safe_default_protocol_versions()
            .unwrap()
            .with_no_client_auth()
            .with_single_cert(certs, key)
            .expect("rustls loads the rcgen-generated X.509 leaf + key");
    }

    /// `provision(SelfGenerated)` exercises the real engine end to end.
    #[test]
    fn provision_self_generated_ok() {
        assert!(provision(&PkiSource::SelfGenerated, None).is_ok());
    }

    /// The Vault sources error WITHOUT a client (honest, no silent fake), but
    /// succeed WITH a real-shape client (covered by the `vault`-gated tests).
    #[test]
    fn vault_sources_require_a_client() {
        assert!(provision(&PkiSource::ExistingVault("https://vault:8200".into()), None).is_err());
        assert!(provision(&PkiSource::TempVault, None).is_err());
    }

    /// `from_seed` is DETERMINISTIC: same hostname+seed → the identical ed25519 CA
    /// identity, proven cryptographically (a leaf issued by CA `a` verifies under
    /// CA `b`'s public key because they are the same seed-derived key), while a
    /// different seed yields a different CA that rejects the leaf.
    #[test]
    fn from_seed_is_deterministic() {
        let a = MachineCa::from_seed("oden", [3u8; 32]);
        let b = MachineCa::from_seed("oden", [3u8; 32]);
        assert_eq!(
            a.verifying_key().to_bytes(),
            b.verifying_key().to_bytes(),
            "same seed → identical CA public key"
        );
        assert_eq!(a.common_name(), b.common_name());
        assert_eq!(a.common_name(), "oden.x14.local");

        let (leaf, _key) = a
            .issue_leaf("svc.oden.x14.local", &["svc.oden.x14.local".into()])
            .unwrap();
        assert!(
            verify_leaf(&leaf, &b.verifying_key()),
            "reproduced CA key verifies a's leaf"
        );

        let c = MachineCa::from_seed("oden", [4u8; 32]);
        assert_ne!(a.verifying_key().to_bytes(), c.verifying_key().to_bytes());
        assert!(
            !verify_leaf(&leaf, &c.verifying_key()),
            "different-seed CA rejects the leaf"
        );
    }

    /// A leaf carrying MULTIPLE DNS SANs plus multiple IP SANs parses as X.509 with
    /// every name present (and in order), and verifies under its CA.
    #[test]
    fn multiple_sans_are_all_present_in_leaf() {
        use x509_parser::prelude::*;

        let ca = MachineCa::from_seed("oden", [11u8; 32]);
        let sans: Vec<String> = vec![
            "a.oden.x14.local".into(),
            "b.oden.x14.local".into(),
            "c.oden.x14.local".into(),
            "10.0.0.7".into(),
            "192.168.1.1".into(),
        ];
        let (leaf, _key) = ca.issue_leaf("multi.oden.x14.local", &sans).unwrap();
        assert_eq!(leaf.sans, sans);

        let (_, x509) = X509Certificate::from_der(&leaf.cert_der).expect("valid X.509");
        let ext = x509
            .subject_alternative_name()
            .expect("SAN ext parses")
            .expect("leaf has SANs");
        let dns: Vec<&str> = ext
            .value
            .general_names
            .iter()
            .filter_map(|g| match g {
                GeneralName::DNSName(d) => Some(*d),
                _ => None,
            })
            .collect();
        assert_eq!(
            dns,
            vec!["a.oden.x14.local", "b.oden.x14.local", "c.oden.x14.local"],
            "all DNS SANs present, in order"
        );
        let ip_count = ext
            .value
            .general_names
            .iter()
            .filter(|g| matches!(g, GeneralName::IPAddress(_)))
            .count();
        assert_eq!(ip_count, 2, "both IP SANs present");
        assert!(verify_leaf(&leaf, &ca.verifying_key()));
    }

    /// A leaf whose DER is mutated by a single byte FAILS verification under its
    /// own CA (signature/parse no longer holds) — tamper detection on the wire.
    #[test]
    fn tampered_leaf_cert_is_rejected() {
        let ca = MachineCa::from_seed("oden", [23u8; 32]);
        let (leaf, _key) = ca
            .issue_leaf("svc.oden.x14.local", &["svc.oden.x14.local".into()])
            .unwrap();
        assert!(
            verify_leaf(&leaf, &ca.verifying_key()),
            "pristine leaf verifies"
        );

        let mut tampered = leaf.clone();
        let mid = tampered.cert_der.len() / 2;
        tampered.cert_der[mid] ^= 0xFF;
        assert!(
            !verify_leaf(&tampered, &ca.verifying_key()),
            "single-byte tamper rejected"
        );
    }
}

#[cfg(all(test, feature = "vault"))]
mod vault_tests {
    use super::vault::*;
    use super::*;
    use crate::mockhttp::MockServer;

    /// A representative `POST /v1/pki/issue/<role>` response body from Vault.
    const VAULT_ISSUE_RESP: &str = r#"{
      "request_id": "abc",
      "lease_duration": 2592000,
      "data": {
        "certificate": "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----",
        "issuing_ca": "-----BEGIN CERTIFICATE-----\nMIIBca\n-----END CERTIFICATE-----",
        "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIBkey\n-----END RSA PRIVATE KEY-----",
        "private_key_type": "rsa",
        "serial_number": "3f:1a:2b:4c:5d"
      }
    }"#;

    /// LAW 1 (inject-assert): the Vault issue REQUEST shape is exactly what the
    /// PKI engine expects — right URL (`/v1/pki/issue/<role>`), token header,
    /// JSON body with `common_name` + comma-joined `alt_names` + `format=pem` —
    /// and the returned cert PARSES into a well-formed [`VaultIssuedCert`].
    #[test]
    fn issue_request_shape_and_response_parse() {
        let server = MockServer::start(1, 200, VAULT_ISSUE_RESP);
        let client = HttpVaultClient::new(server.base_url(), "s.token-123", "server")
            .unwrap()
            .ttl("720h");

        // Make the real HTTP call on a worker; the mock captures the request.
        let url = client.issue_url();
        let cert = std::thread::scope(|s| {
            let h = s.spawn(|| client.issue("holger.oden.x14.local", &["holger".into(), "10.0.0.5".into()]));
            let req = server.next_request();
            // Request line.
            assert_eq!(req.method, "POST");
            assert_eq!(req.path, "/v1/pki/issue/server");
            assert!(url.ends_with("/v1/pki/issue/server"));
            // Auth header (the Vault token).
            assert_eq!(req.header("X-Vault-Token"), Some("s.token-123"));
            // Body shape.
            let body: serde_json::Value = serde_json::from_str(&req.body).unwrap();
            assert_eq!(body["common_name"], "holger.oden.x14.local");
            assert_eq!(body["alt_names"], "holger,10.0.0.5");
            assert_eq!(body["format"], "pem");
            assert_eq!(body["ttl"], "720h");
            h.join().unwrap()
        })
        .expect("issue ok");

        // Response parsed into a real cert.
        assert_eq!(cert.subject_cn, "holger.oden.x14.local");
        assert_eq!(cert.sans, vec!["holger", "10.0.0.5"]);
        assert!(cert.certificate_pem.contains("BEGIN CERTIFICATE"));
        assert!(cert.private_key_pem.contains("BEGIN RSA PRIVATE KEY"));
        assert!(cert.issuing_ca_pem.contains("BEGIN CERTIFICATE"));
        assert_eq!(cert.serial_number, "3f:1a:2b:4c:5d");
        assert!(cert.is_well_formed());
    }

    /// The pure response parser rejects a malformed (missing-field) body.
    #[test]
    fn parse_rejects_missing_fields() {
        assert!(parse_issue_response("cn", &[], r#"{"data":{"certificate":"x"}}"#).is_err());
        assert!(parse_issue_response("cn", &[], r#"{}"#).is_err());
    }

    /// A non-2xx Vault response is surfaced as an error (with the body).
    #[test]
    fn http_error_is_surfaced() {
        let server = MockServer::start(1, 403, r#"{"errors":["permission denied"]}"#);
        let client = HttpVaultClient::new(server.base_url(), "bad", "server").unwrap();
        let res = std::thread::scope(|s| {
            let h = s.spawn(|| client.issue("cn", &[]));
            let _ = server.next_request();
            h.join().unwrap()
        });
        let err = res.unwrap_err().to_string();
        assert!(err.contains("403"), "surfaces HTTP status: {err}");
    }

    /// `provision(ExistingVault, Some(client))` drives the real issue call.
    #[test]
    fn provision_existing_vault_with_client() {
        let server = MockServer::start(1, 200, VAULT_ISSUE_RESP);
        let client = HttpVaultClient::new(server.base_url(), "tok", "server").unwrap();
        let res = std::thread::scope(|s| {
            let h = s.spawn(|| provision(&PkiSource::ExistingVault(String::new()), Some(&client)));
            let _ = server.next_request();
            h.join().unwrap()
        });
        assert!(res.is_ok(), "provision via Vault client succeeds: {res:?}");
    }

    /// `TempVault::spawn` is honest when the `vault` binary is absent.
    #[test]
    fn temp_vault_needs_the_binary() {
        if std::process::Command::new("vault").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
            return; // staging host has vault — skip the absence assertion
        }
        assert!(TempVaultClient::spawn("server").is_err());
    }
}
