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

nordisk/monetize

crates/lake-product/src/signed.rs

//! **The signed guests' axes of the Lake bench** (`--cloud upcloud|scaleway`,
//! `--vendor stripe|klarna|swish`): monetize-impl's signed cloud and payment guests, loaded by
//! the ONE box's own registry from the blocks [`blocks`] writes, against their fakes:
//!
//! | axis | the guest (signed here, under the run's CA) | its fake |
//! |---|---|---|
//! | `upcloud` | `guests/upcloud` | `mock-upcloud`, in process (`mock_upcloud::serve`) |
//! | `scaleway` | `guests/scaleway` | `mock-scaleway`, in process (`Mock::serve_on`) |
//! | `stripe` | `guests/stripe` | `fake_stripe_serve`, a process; its control door is the buyer |
//! | `klarna` | `guests/klarna` | `fake_klarna_serve`, a process; its control door is the buyer |
//! | `swish` | `guests/swish` | `fake_swish_serve`, a process; its control door is the payer |
//!
//! The open repo holds none of them: `cargo xtask bench lake` builds them in the sibling
//! `../monetize-impl` at run time and hands this bench ONE directory ([`Signed`]) with
//! `<axis>.wasm`, `fake_<vendor>_serve` and, for Swish, `swish-client.pem` (the sibling's test
//! certificate, not a secret). A missing file is refused by name.
//!
//! The money flows for real through the guest: the fake's page is paid by its control door,
//! the vendor's signed callback is posted to the ONE box's `/callback/<kind>/lake`, the core
//! sees the hold through the guest's `status` and captures it through the guest's `capture`.

use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};

use crate::say;

/// The signed clouds and vendors.
pub const CLOUDS: [&str; 2] = ["upcloud", "scaleway"];
pub const VENDORS: [&str; 3] = ["stripe", "klarna", "swish"];

/// The vendors' instance in the bench's config (`[vendors.<kind>.lake]`).
pub const INSTANCE: &str = "lake";
/// The test credentials the fakes are started with and the secrets hold (never real ones).
pub const STRIPE_KEY: &str = "sk_test_LAKEbenchNEVERlive";
pub const STRIPE_WEBHOOK: &str = "whsec_test_LAKEbench";
pub const KLARNA_USER: &str = "PK_test_lake_bench";
pub const KLARNA_PASSWORD: &str = "lake-bench-klarna-test-password";
pub const KLARNA_CALLBACK: &str = "lakecb:lake-bench-callback-test";
pub const SWISH_PAYEE: &str = "1234679304";
pub const SWISH_CALLBACK: &str = "LAKEbenchSWISHcallbackIDENTIFIER0001";
pub const UPCLOUD_TOKEN: &str = "ucat_mock_lake_bench_not_a_real_token";
/// The zones the signed clouds lay in.
pub const UPCLOUD_ZONE: &str = "se-sto1";
pub const SCALEWAY_ZONE: &str = "fr-par-1";
/// Where the vendors are told the ONE box takes their callbacks (the bench posts them itself).
pub const SHOP: &str = "https://one.mock.invalid";

/// Is `name` one of the signed axes?
pub fn is_signed(name: &str) -> bool {
    CLOUDS.contains(&name) || VENDORS.contains(&name)
}

/// **The directory the xtask hands the bench**: the built guests and fakes, by name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Signed(pub PathBuf);

impl Signed {
    /// The guest module of `axis` (`<axis>.wasm`), refused by name when it is not there.
    pub fn guest(&self, axis: &str) -> Result<PathBuf, String> {
        self.file(&format!("{axis}.wasm"), &format!("the signed {axis} guest"))
    }
    /// The fake of `vendor` (`fake_<vendor>_serve`).
    pub fn fake(&self, vendor: &str) -> Result<PathBuf, String> {
        self.file(
            &format!("fake_{vendor}_serve"),
            &format!("the fake {vendor} (monetize-pay-impl's example fake_{vendor}_serve)"),
        )
    }
    /// Swish's test client certificate (`swish-client.pem`, monetize-impl's test fixture).
    pub fn swish_pem(&self) -> Result<PathBuf, String> {
        self.file("swish-client.pem", "Swish's test client certificate")
    }
    fn file(&self, name: &str, what: &str) -> Result<PathBuf, String> {
        let p = self.0.join(name);
        if p.is_file() {
            Ok(p)
        } else {
            Err(format!("{what} is not at {}: `cargo xtask bench lake` builds it from the sibling ../monetize-impl", p.display()))
        }
    }
    /// Every file `cloud` and `vendor` need, refused by the first missing name. A dummy axis
    /// needs nothing.
    pub fn check(&self, cloud: &str, vendor: &str) -> Result<(), String> {
        if CLOUDS.contains(&cloud) {
            self.guest(cloud)?;
        }
        if VENDORS.contains(&vendor) {
            self.guest(vendor)?;
            self.fake(vendor)?;
            if vendor == "swish" {
                self.swish_pem()?;
            }
        }
        Ok(())
    }
}

/// The vendor's name in the registry (`<kind>/lake`).
pub fn vendor_name(vendor: &str) -> String {
    format!("{vendor}/{INSTANCE}")
}

/// The sale's currency a vendor takes: Swish takes SEK only, the others EUR.
pub fn vendor_currency(vendor: &str) -> &'static str {
    if vendor == "swish" {
        "SEK"
    } else {
        "EUR"
    }
}

fn free_port() -> Result<u16, String> {
    Ok(std::net::TcpListener::bind("127.0.0.1:0")
        .and_then(|l| l.local_addr())
        .map_err(|e| e.to_string())?
        .port())
}

fn listening(port: u16) -> bool {
    std::net::TcpStream::connect(("127.0.0.1", port)).is_ok()
}

/// A guest copied into the run and signed under the run's CA.
fn sign_into(
    dir: &Path,
    from: &Path,
    name: &str,
    signer: &dyn guest_host::verify::sign::ArchiveSigner,
) -> Result<PathBuf, String> {
    let module = dir.join(format!("{name}.wasm"));
    std::fs::copy(from, &module).map_err(|e| format!("{}: {e}", from.display()))?;
    guest_host::verify::sign::sign_module_file(&module, signer)?;
    Ok(module)
}

// ── the clouds ─────────────────────────────────────────────────────────────────────────────

/// **A signed cloud's fake, in this process**, and the media the shop lays from (the
/// release's storages or snapshots at the mock, by kind and release).
pub enum CloudFake {
    UpCloud {
        mock: Arc<mock_upcloud::Mock>,
        base: String,
    },
    Scaleway {
        mock: Arc<mock_scaleway::Mock>,
    },
}

impl CloudFake {
    /// Started on loopback, with `media` (`lake-r1`, `lake-keep-r1`, …) made at the mock.
    pub async fn start(
        cloud: &str,
        media: &[String],
    ) -> Result<(CloudFake, BTreeMap<String, String>), String> {
        let mut ids = BTreeMap::new();
        match cloud {
            "upcloud" => {
                let mock = mock_upcloud::Mock::recording(mock_upcloud::estate::Estate::new(
                    mock_upcloud::Clock::new(1_000_000),
                    mock_upcloud::Faults::none(),
                    11,
                ));
                {
                    let mut e = mock.estate.lock().unwrap();
                    for m in media {
                        ids.insert(
                            m.clone(),
                            e.create_storage(m, 1, "maxiops", UPCLOUD_ZONE, Vec::new())
                                .map_err(|r| {
                                    format!("the UpCloud mock refused the medium {m}: {r:?}")
                                })?,
                        );
                    }
                }
                let (port, _task) = mock_upcloud::serve(mock.clone(), "127.0.0.1:0")
                    .await
                    .map_err(|e| format!("mock-upcloud: {e}"))?;
                say(
                    "bench",
                    "cloud",
                    "bench",
                    &format!(
                        "mock-upcloud at 127.0.0.1:{port}, {} medium storage(s)",
                        ids.len()
                    ),
                );
                Ok((
                    CloudFake::UpCloud {
                        mock,
                        base: format!("http://127.0.0.1:{port}"),
                    },
                    ids,
                ))
            }
            "scaleway" => {
                let mock = mock_scaleway::Mock::new(mock_scaleway::Clock::new(1_000_000));
                let port = mock
                    .serve_on("127.0.0.1:0")
                    .map_err(|e| format!("mock-scaleway: {e}"))?;
                // A release medium at Scaleway is a SNAPSHOT: a volume made, then snapshotted.
                for m in media {
                    let (project, tok) = (mock.project(), mock.token());
                    let body = serde_json::json!({ "name": m, "project": project, "volume_type": "b_ssd", "size": 20_000_000_000u64 });
                    let vol = mock.with(|a| {
                        match a.handle(
                            "POST",
                            &format!("/instance/v1/zones/{SCALEWAY_ZONE}/volumes"),
                            &[],
                            Some(&tok),
                            Some("application/json"),
                            &serde_json::to_vec(&body).unwrap_or_default(),
                        ) {
                            mock_scaleway::Answer::Json { body, .. } => {
                                body["volume"]["id"].as_str().map(str::to_string)
                            }
                            _ => None,
                        }
                    });
                    let vol = vol.ok_or_else(|| {
                        format!("the Scaleway mock made no volume for the medium {m}")
                    })?;
                    let snap = mock.snapshot_of(&vol, &[]).ok_or_else(|| {
                        format!("the Scaleway mock made no snapshot for the medium {m}")
                    })?;
                    ids.insert(m.clone(), snap);
                }
                say(
                    "bench",
                    "cloud",
                    "bench",
                    &format!(
                        "mock-scaleway at 127.0.0.1:{port}, {} medium snapshot(s)",
                        ids.len()
                    ),
                );
                Ok((CloudFake::Scaleway { mock }, ids))
            }
            other => Err(format!("{other} is not a signed cloud ({CLOUDS:?})")),
        }
    }

    pub fn name(&self) -> &'static str {
        match self {
            CloudFake::UpCloud { .. } => "upcloud",
            CloudFake::Scaleway { .. } => "scaleway",
        }
    }

    pub fn zone(&self) -> &'static str {
        match self {
            CloudFake::UpCloud { .. } => UPCLOUD_ZONE,
            CloudFake::Scaleway { .. } => SCALEWAY_ZONE,
        }
    }

    /// The servers the mock itself still holds (its own word, beside the guest's inventory).
    pub fn servers_left(&self) -> usize {
        match self {
            CloudFake::UpCloud { mock, .. } => mock
                .estate
                .lock()
                .map(|e| e.servers_matching(&[]).len())
                .unwrap_or(usize::MAX),
            CloudFake::Scaleway { mock } => mock.servers().len(),
        }
    }

    /// **The keep medium, played** (Scaleway): a machine booted from a keep medium installs and
    /// powers itself off, which mock-upcloud models and mock-scaleway does not. While `run` is
    /// set, a running server whose boot volume is a keep clone (`…-keep…`) is powered off, as
    /// its medium would. UpCloud: nothing to play.
    pub fn play_keep_media(
        &self,
        run: Arc<std::sync::atomic::AtomicBool>,
    ) -> Option<std::thread::JoinHandle<()>> {
        let CloudFake::Scaleway { mock } = self else {
            return None;
        };
        let mock = mock.clone();
        Some(std::thread::spawn(move || {
            while run.load(std::sync::atomic::Ordering::SeqCst) {
                for s in mock.servers() {
                    let boot = s["volumes"]["0"]["name"].as_str().unwrap_or_default();
                    if s["state"] == "running" && boot.contains("-keep") {
                        let (id, tok) = (
                            s["id"].as_str().unwrap_or_default().to_string(),
                            mock.token(),
                        );
                        let zone = s["zone"].as_str().unwrap_or(SCALEWAY_ZONE).to_string();
                        mock.with(|a| {
                            a.handle(
                                "POST",
                                &format!("/instance/v1/zones/{zone}/servers/{id}/action"),
                                &[],
                                Some(&tok),
                                Some("application/json"),
                                br#"{"action":"poweroff"}"#,
                            )
                        });
                        say(
                            "bench",
                            "keep",
                            "bench",
                            &format!(
                                "the keep medium of {} installed and powered it off",
                                s["name"].as_str().unwrap_or(&id)
                            ),
                        );
                    }
                }
                std::thread::sleep(Duration::from_millis(200));
            }
        }))
    }

    /// `[clouds.<name>]`, the guest signed into `dir`, its key a secret in `secrets`.
    fn block(
        &self,
        dir: &Path,
        signed: &Signed,
        signer: &dyn guest_host::verify::sign::ArchiveSigner,
        root: &Path,
        secrets: &Path,
    ) -> Result<String, String> {
        let module = sign_into(dir, &signed.guest(self.name())?, self.name(), signer)?;
        Ok(match self {
            CloudFake::UpCloud { base, .. } => {
                write_secret(secrets, "upcloud_token", UPCLOUD_TOKEN)?;
                cloud_block(
                    "upcloud",
                    &module,
                    root,
                    base,
                    UPCLOUD_ZONE,
                    "Bearer ",
                    "",
                    "upcloud_token",
                    "country = \"FI\"\nallow_grow = true\n",
                )
            }
            CloudFake::Scaleway { mock } => {
                write_secret(secrets, "scaleway_token", &mock.token())?;
                cloud_block(
                    "scaleway",
                    &module,
                    root,
                    &mock.base_url(),
                    SCALEWAY_ZONE,
                    "",
                    "X-Auth-Token",
                    "scaleway_token",
                    &format!(
                        "country = \"FR\"\nproject = {:?}\npoll_ms = 5\nwait_s = 60\n",
                        mock.project()
                    ),
                )
            }
        })
    }
}

/// Pure: **a signed cloud's block** — the guest, its trust root, its grants and hosts, the key
/// the HOST presents (`http_auth`), and a spend cap.
#[allow(clippy::too_many_arguments)]
pub fn cloud_block(
    name: &str,
    module: &Path,
    root: &Path,
    base: &str,
    zone: &str,
    prefix: &str,
    header: &str,
    secret: &str,
    extra: &str,
) -> String {
    let header = if header.is_empty() {
        String::new()
    } else {
        format!("header = {header:?}\n")
    };
    format!(
        "\n[clouds.{name}]\nenabled = true\nguest = {:?}\nguest_trust_roots = [{:?}]\ngrants = [\"http\", \"state\"]\nhttp_hosts = [{base:?}]\napi_base = {base:?}\nzone = {zone:?}\ncap = {{ monthly_minor = 100000000, currency = \"Eur\" }}\n{extra}[[clouds.{name}.http_auth]]\nhost = {base:?}\n{header}prefix = {prefix:?}\nsecret = {secret:?}\n",
        module.display().to_string(),
        root.display().to_string()
    )
}

// ── the vendors ────────────────────────────────────────────────────────────────────────────

/// **A signed vendor's fake, as a process** (only this child is ever stopped): its API, which
/// the guest dials, and its control door, which is the buyer.
pub struct VendorFake {
    pub kind: String,
    child: Child,
    pub api: u16,
    pub control: u16,
}

impl VendorFake {
    pub fn start(kind: &str, signed: &Signed) -> Result<VendorFake, String> {
        let bin = signed.fake(kind)?;
        let (api, control) = (free_port()?, free_port()?);
        let (a, c) = (api.to_string(), control.to_string());
        let args: Vec<&str> = match kind {
            "stripe" => vec![
                "--port",
                &a,
                "--control",
                &c,
                "--key",
                STRIPE_KEY,
                "--webhook",
                STRIPE_WEBHOOK,
                "--ids-like-stripe",
                "true",
            ],
            "klarna" => vec![
                "--port",
                &a,
                "--control",
                &c,
                "--user",
                KLARNA_USER,
                "--password",
                KLARNA_PASSWORD,
            ],
            "swish" => vec!["--port", &a, "--control", &c, "--payee", SWISH_PAYEE],
            other => return Err(format!("{other} is not a signed vendor ({VENDORS:?})")),
        };
        let child = Command::new(&bin)
            .args(&args)
            .stdout(Stdio::null())
            .stderr(Stdio::inherit())
            .spawn()
            .map_err(|e| format!("{}: {e}", bin.display()))?;
        let mut fake = VendorFake {
            kind: kind.into(),
            child,
            api,
            control,
        };
        let until = Instant::now() + Duration::from_secs(20);
        while !(listening(api) && listening(control)) {
            if let Ok(Some(st)) = fake.child.try_wait() {
                return Err(format!("the fake {kind} ended before it listened: {st}"));
            }
            if Instant::now() > until {
                return Err(format!(
                    "the fake {kind} did not listen on {api}/{control} in 20 s"
                ));
            }
            std::thread::sleep(Duration::from_millis(50));
        }
        say(
            "bench",
            "vendor",
            "bench",
            &format!(
                "fake {kind} (pid {}) at 127.0.0.1:{api}, its buyer's door at 127.0.0.1:{control}",
                fake.child.id()
            ),
        );
        Ok(fake)
    }

    pub fn base(&self) -> String {
        format!("http://127.0.0.1:{}", self.api)
    }

    fn control(&self, method: &str, path: &str) -> Result<serde_json::Value, String> {
        let addr: SocketAddr = ([127, 0, 0, 1], self.control).into();
        // The fakes' control doors read the request line only and close: a reset while the rest
        // of the request is still being written is asked again.
        let mut tries = 0;
        let answer = loop {
            match crate::http::call(addr, method, path, "") {
                Err(e) if tries < 5 && (e.contains("reset") || e.contains("Broken pipe")) => {
                    tries += 1;
                    std::thread::sleep(Duration::from_millis(100));
                }
                other => break other?,
            }
        };
        match answer {
            (200, b) => serde_json::from_str(&b)
                .map_err(|e| format!("the fake {}'s {method} {path}: {e}: {b}", self.kind)),
            (s, b) => Err(format!(
                "the fake {}'s page refused {method} {path}: {s} {b}",
                self.kind
            )),
        }
    }

    /// **The buyer pays the page of `handle`** (`checkout` is the page's URL) at the fake, and
    /// the vendor's signed callback is delivered to the ONE box at `one`. Answers what happened.
    pub fn pay(
        &self,
        one: SocketAddr,
        handle: &monetize_pay::ChargeHandle,
        checkout: &str,
    ) -> Result<String, String> {
        let (path, headers, body, what) = match self.kind.as_str() {
            "stripe" => {
                let cs = session_of(checkout);
                let ev = self.control("POST", &format!("/pay/{cs}"))?;
                let sig = ev["signature"].as_str().unwrap_or_default().to_string();
                (
                    "/callback/stripe/lake",
                    vec![("Stripe-Signature".to_string(), sig)],
                    ev["body"].as_str().unwrap_or_default().to_string(),
                    format!("paid {cs} at the fake Stripe's page"),
                )
            }
            "klarna" => {
                let ev = self.control("POST", &format!("/authorize/{}", handle.id))?;
                let auth = ev["authorization"].as_str().unwrap_or_default().to_string();
                (
                    "/callback/klarna/lake",
                    vec![("Authorization".to_string(), auth)],
                    ev["body"].as_str().unwrap_or_default().to_string(),
                    format!(
                        "authorized {} at the fake Klarna's page (order {})",
                        handle.id,
                        ev["order"].as_str().unwrap_or("?")
                    ),
                )
            }
            "swish" => {
                let ev = self.control("POST", &format!("/pay/{}", handle.id))?;
                let ci = ev["callbackIdentifier"]
                    .as_str()
                    .unwrap_or_default()
                    .to_string();
                (
                    "/callback/swish/lake",
                    vec![("callbackIdentifier".to_string(), ci)],
                    ev["body"].as_str().unwrap_or_default().to_string(),
                    format!("paid {} in the fake Swish app", handle.id),
                )
            }
            other => return Err(format!("{other} is not a signed vendor")),
        };
        let mut h = headers;
        h.push(("Content-Type".into(), "application/json".into()));
        match crate::http::call_with(one, "POST", path, &h, &body)? {
            (200..=299, b) => {
                say(
                    "vendor",
                    "callback",
                    &self.kind,
                    &format!("{path} answered {}", b.trim()),
                );
                Ok(format!("{what}; its callback taken at {path}"))
            }
            (s, b) => Err(format!(
                "{what}; the ONE box refused its callback at {path}: {s} {b}"
            )),
        }
    }

    /// The fake's own word on the page's money, where its control door says it (Stripe's
    /// `GET /calls/<cs>`: what it was asked to capture, cancel or expire).
    pub fn calls_word(&self, checkout: &str) -> Option<String> {
        if self.kind != "stripe" {
            return None;
        }
        self.control("GET", &format!("/calls/{}", session_of(checkout)))
            .ok()
            .map(|v| format!("the fake Stripe was asked {v}"))
    }

    /// `[vendors.<kind>.lake]`, the guest signed into `dir`, its secrets in `secrets`.
    fn block(
        &self,
        dir: &Path,
        signed: &Signed,
        signer: &dyn guest_host::verify::sign::ArchiveSigner,
        root: &Path,
        secrets: &Path,
    ) -> Result<String, String> {
        let module = sign_into(dir, &signed.guest(&self.kind)?, &self.kind, signer)?;
        let base = self.base();
        Ok(match self.kind.as_str() {
            "stripe" => {
                write_secret(secrets, "stripe_lake_key", STRIPE_KEY)?;
                write_secret(secrets, "stripe_lake_webhook", STRIPE_WEBHOOK)?;
                vendor_block("stripe", &module, root, &base, "stripe_lake_webhook", &format!("country = \"IE\"\nbase_url = {base:?}\nsuccess_url = \"https://lake.mock.invalid/paid/{{reference}}\"\ncancel_url = \"https://lake.mock.invalid/cancel/{{reference}}\"\nlivemode = false\nwebhook_secret = \"stripe_lake_webhook\"\n"), "prefix = \"Bearer \"\nsecret = \"stripe_lake_key\"\n")
            }
            "klarna" => {
                write_secret(secrets, "klarna_user", KLARNA_USER)?;
                write_secret(secrets, "klarna_password", KLARNA_PASSWORD)?;
                write_secret(secrets, "klarna_callback", KLARNA_CALLBACK)?;
                vendor_block("klarna", &module, root, &base, "klarna_callback", &format!("country = \"FI\"\nbase_url = {base:?}\ncallback_url = \"{SHOP}/callback/klarna/lake\"\nreturn_url = \"https://lake.mock.invalid/paid/{{reference}}\"\n"), "basic_user_secret = \"klarna_user\"\nsecret = \"klarna_password\"\nbody_secrets = [\"klarna_callback\"]\n")
            }
            "swish" => {
                let pem = std::fs::read(signed.swish_pem()?).map_err(|e| e.to_string())?;
                write_secret(secrets, "swish_client", &String::from_utf8_lossy(&pem))?;
                write_secret(secrets, "swish_callback", SWISH_CALLBACK)?;
                vendor_block("swish", &module, root, &base, "swish_callback", &format!("country = \"SE\"\nbase_url = \"{base}/swish-cpcapi\"\npayee_alias = {SWISH_PAYEE:?}\ncallback_url = \"{SHOP}/callback/swish/lake\"\ncallback_secret = \"swish_callback\"\n"), "client_cert_secret = \"swish_client\"\nbody_secrets = [\"swish_callback\"]\n")
            }
            other => return Err(format!("{other} is not a signed vendor")),
        })
    }
}

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

/// Pure: **a signed vendor's block** — the guest, its trust root, its grants and host, the
/// secret its callbacks are checked with on the host (`hmac_secrets`), and the credential the
/// HOST presents (`http_auth`).
pub fn vendor_block(
    kind: &str,
    module: &Path,
    root: &Path,
    base: &str,
    hmac: &str,
    settings: &str,
    auth: &str,
) -> String {
    format!(
        "\n[vendors.{kind}.{INSTANCE}]\nenabled = true\nguest = {:?}\nguest_trust_roots = [{:?}]\ngrants = [\"http\", \"hmac\", \"state\"]\nhttp_hosts = [{base:?}]\nhmac_secrets = [{hmac:?}]\n{settings}[[vendors.{kind}.{INSTANCE}.http_auth]]\nhost = {base:?}\n{auth}",
        module.display().to_string(),
        root.display().to_string()
    )
}

/// The checkout session of a Stripe page URL (`…/c/pay/cs_test_…#…`).
pub fn session_of(url: &str) -> String {
    url.rsplit('/')
        .next()
        .unwrap_or_default()
        .split('#')
        .next()
        .unwrap_or_default()
        .split('?')
        .next()
        .unwrap_or_default()
        .to_owned()
}

fn write_secret(dir: &Path, name: &str, value: &str) -> Result<(), String> {
    monetize_product_wasm::secrets::write(dir, name, value.as_bytes()).map(|_| ())
}

/// **The signed blocks of the run**: a CA minted for it, every chosen guest signed under it,
/// the blocks for `monetize.toml` (to be appended) and the secrets written owner-only into
/// `secrets`.
pub fn blocks(
    dir: &Path,
    signed: &Signed,
    cloud: Option<&CloudFake>,
    vendor: Option<&VendorFake>,
    secrets: &Path,
) -> Result<String, String> {
    use guest_host::verify::sign::dev;
    let (ca_key, ca_der) = dev::mint_ca("lake-bench-signed-root").map_err(|e| e.to_string())?;
    let root = dir.join("signed-root.der");
    std::fs::write(&root, &ca_der).map_err(|e| e.to_string())?;
    let signer = dev::new_ed25519_signer(&ca_key, &ca_der, "lake-bench-signed-signing")
        .map_err(|e| e.to_string())?;
    let mut t = String::new();
    if let Some(c) = cloud {
        t.push_str(&c.block(dir, signed, &signer, &root, secrets)?);
    }
    if let Some(v) = vendor {
        t.push_str(&v.block(dir, signed, &signer, &root, secrets)?);
    }
    Ok(t)
}

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

    #[test]
    fn the_signed_axes_are_named_and_a_missing_file_is_refused_by_name() {
        for a in ["upcloud", "scaleway", "stripe", "klarna", "swish"] {
            assert!(is_signed(a), "{a}");
        }
        assert!(!is_signed("mock") && !is_signed("sandbox"));
        let dir = std::env::temp_dir().join(format!("lake-signed-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let s = Signed(dir.clone());
        assert_eq!(s.check("mock", "sandbox"), Ok(()), "a dummy needs nothing");
        let e = s.check("upcloud", "mock").unwrap_err();
        assert!(
            e.contains("the signed upcloud guest") && e.contains("../monetize-impl"),
            "{e}"
        );
        std::fs::write(dir.join("upcloud.wasm"), b"x").unwrap();
        std::fs::write(dir.join("swish.wasm"), b"x").unwrap();
        let e = s.check("upcloud", "swish").unwrap_err();
        assert!(e.contains("fake_swish_serve"), "{e}");
        std::fs::write(dir.join("fake_swish_serve"), b"x").unwrap();
        let e = s.check("upcloud", "swish").unwrap_err();
        assert!(e.contains("swish-client.pem"), "{e}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn a_cloud_block_names_its_guest_its_root_its_grants_and_the_key_the_host_presents() {
        let b = cloud_block(
            "scaleway",
            Path::new("/r/scaleway.wasm"),
            Path::new("/r/root.der"),
            "http://127.0.0.1:9",
            SCALEWAY_ZONE,
            "",
            "X-Auth-Token",
            "scaleway_token",
            "project = \"p\"\n",
        );
        let t: toml::Value = toml::from_str(&b).unwrap();
        let c = &t["clouds"]["scaleway"];
        assert_eq!(c["guest"].as_str(), Some("/r/scaleway.wasm"));
        assert_eq!(c["guest_trust_roots"][0].as_str(), Some("/r/root.der"));
        assert_eq!(c["grants"].as_array().unwrap().len(), 2);
        assert_eq!(c["http_hosts"][0].as_str(), Some("http://127.0.0.1:9"));
        assert_eq!(c["http_auth"][0]["header"].as_str(), Some("X-Auth-Token"));
        assert_eq!(c["http_auth"][0]["secret"].as_str(), Some("scaleway_token"));
        assert!(c["cap"]["monthly_minor"].as_integer().unwrap() > 0);
        let u: toml::Value = toml::from_str(&cloud_block(
            "upcloud",
            Path::new("/u.wasm"),
            Path::new("/root.der"),
            "http://127.0.0.1:8",
            UPCLOUD_ZONE,
            "Bearer ",
            "",
            "upcloud_token",
            "",
        ))
        .unwrap();
        assert!(
            u["clouds"]["upcloud"]["http_auth"][0]
                .get("header")
                .is_none(),
            "UpCloud's key is the Authorization header"
        );
    }

    #[test]
    fn a_vendor_block_names_its_guest_its_hmac_secret_and_its_credential() {
        let b = vendor_block(
            "klarna",
            Path::new("/k.wasm"),
            Path::new("/root.der"),
            "http://127.0.0.1:7",
            "klarna_callback",
            "base_url = \"http://127.0.0.1:7\"\n",
            "basic_user_secret = \"klarna_user\"\nsecret = \"klarna_password\"\n",
        );
        let t: toml::Value = toml::from_str(&b).unwrap();
        let v = &t["vendors"]["klarna"]["lake"];
        assert_eq!(v["guest"].as_str(), Some("/k.wasm"));
        assert_eq!(
            v["grants"]
                .as_array()
                .unwrap()
                .iter()
                .map(|g| g.as_str().unwrap())
                .collect::<Vec<_>>(),
            ["http", "hmac", "state"]
        );
        assert_eq!(v["hmac_secrets"][0].as_str(), Some("klarna_callback"));
        assert_eq!(
            v["http_auth"][0]["basic_user_secret"].as_str(),
            Some("klarna_user")
        );
        assert_eq!(vendor_name("klarna"), "klarna/lake");
        assert_eq!(
            (vendor_currency("swish"), vendor_currency("stripe")),
            ("SEK", "EUR")
        );
        assert_eq!(
            session_of("https://pay.mock.invalid/c/pay/cs_test_7#frag"),
            "cs_test_7"
        );
    }
}