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

nordisk/monetize

crates/monetize-product-wasm/tests/a_guest_is_replaced_live.rs

//! **A guest is replaced while it serves** — no restart, no rebuild: the new
//! module passes every gate a start does, is compiled before anything is
//! swapped, and calls already in flight finish on the old one. A refused
//! replacement (a bad signature, bytes nobody pinned) leaves the old guest
//! serving.

mod common;

use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;

use common::*;
use guest_host::verify::sign;
use monetize_product::GuestSpec;
use monetize_product_wasm::{GuestProduct, HotGuest};

/// One call into the guest: its plan (op 91), which every toy answers.
fn plan(g: &GuestProduct) -> Result<serde_json::Value, monetize_product::ProductError> {
    g.lifecycle_lay_plan(&monetize_product_guest::wire::LayPlanReq { company: "t".into(), ..Default::default() })
}

/// The toy guest with a custom section appended: a valid module, the same
/// behaviour, different bytes — so a different digest, which is what a
/// rebuilt guest is to the gate.
fn toy_variant(dir: &Path, name: &str, tag: &str) -> std::path::PathBuf {
    let mut bytes = std::fs::read(built_toy()).unwrap();
    let mut content = vec![tag.len() as u8];
    content.extend_from_slice(tag.as_bytes());
    content.extend_from_slice(b"hot-load");
    assert!(content.len() < 128, "one-byte LEB128 size");
    bytes.push(0); // custom section
    bytes.push(content.len() as u8);
    bytes.extend_from_slice(&content);
    let p = dir.join(name);
    std::fs::write(&p, bytes).unwrap();
    p
}

#[test]
fn a_replacement_swaps_live_and_a_call_in_flight_finishes_on_the_old_guest() {
    let dir = scratch("hot-swap");
    let v1 = toy_copy(&dir);
    let spec1 = toy_spec(&v1);
    let hot = HotGuest::build(&build("toy", &spec1)).unwrap();
    let a = digest_of(&v1);
    assert_eq!(hot.current().digest_hex(), a);
    assert!(hot.is_current_for(&build("toy", &spec1)).unwrap(), "an unchanged block is not a change");

    // A call that has already taken its instance — what `current()` hands every verb.
    let in_flight = hot.current();

    let v2 = toy_variant(&dir, "toy-v2.wasm", "v2");
    let spec2 = toy_spec(&v2);
    let b = digest_of(&v2);
    assert_ne!(a, b);
    assert!(!hot.is_current_for(&build("toy", &spec2)).unwrap(), "new bytes are a change");
    let r = hot.replace(&build("toy", &spec2)).unwrap();
    assert_eq!((r.id, r.from_digest.as_str(), r.to_digest.as_str()), ("toy", a.as_str(), b.as_str()));

    // New calls go to the new guest; the one in flight is still the old one, and still answers.
    assert_eq!(hot.current().digest_hex(), b);
    assert_eq!(in_flight.digest_hex(), a);
    assert!(plan(&in_flight).is_ok());
    assert!(plan(&hot.current()).is_ok());
    assert!(hot.is_current_for(&build("toy", &spec2)).unwrap());
    assert_eq!(hot.facts().meters.len(), in_flight.meter_table().len());

    // Another id cannot be moved in under this one.
    assert!(hot.replace(&build("other", &spec2)).unwrap_err().contains("cannot move a guest to another id"));
    let _ = std::fs::remove_dir_all(&dir);
}

/// **Callers never see a gap.** Four threads call through the product while
/// it is replaced back and forth; every call answers.
#[test]
fn calls_keep_answering_while_the_guest_is_replaced_under_them() {
    let dir = scratch("hot-busy");
    let v1 = toy_copy(&dir);
    let v2 = toy_variant(&dir, "toy-v2.wasm", "v2");
    let (spec1, spec2) = (toy_spec(&v1), toy_spec(&v2));
    let hot = Arc::new(HotGuest::build(&build("toy", &spec1)).unwrap());
    let stop = Arc::new(AtomicBool::new(false));
    let (ok, failed) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
    let callers: Vec<_> = (0..4)
        .map(|_| {
            let (hot, stop, ok, failed) = (hot.clone(), stop.clone(), ok.clone(), failed.clone());
            std::thread::spawn(move || {
                while !stop.load(Ordering::Relaxed) {
                    match plan(&hot.current()) {
                        Ok(_) => ok.fetch_add(1, Ordering::Relaxed),
                        Err(_) => failed.fetch_add(1, Ordering::Relaxed),
                    };
                }
            })
        })
        .collect();
    for i in 0..6 {
        let spec = if i % 2 == 0 { &spec2 } else { &spec1 };
        hot.replace(&build("toy", spec)).unwrap();
    }
    stop.store(true, Ordering::Relaxed);
    for c in callers {
        c.join().unwrap();
    }
    assert_eq!(hot.current().digest_hex(), digest_of(&v1), "six swaps end where they began");
    assert!(ok.load(Ordering::Relaxed) > 0, "no call ran during the swaps");
    assert_eq!(failed.load(Ordering::Relaxed), 0, "a call failed while the guest was replaced");
    let _ = std::fs::remove_dir_all(&dir);
}

/// **A bad signature is refused, and the old guest keeps serving.** The
/// operator drops a new module and its `.p7s` in place of the old; signed by
/// a key the block's roots do not trust, it is refused by the same gate a
/// start uses — and the running guest never stops answering.
#[test]
fn a_replacement_with_a_bad_signature_is_refused_and_the_old_guest_keeps_serving() {
    let dir = scratch("hot-badsig");
    let module = toy_copy(&dir);
    let (root, trusted) = mint_signer(&dir, "release-door");
    let (_, stranger) = mint_signer(&dir, "stranger");
    sign::sign_module_file(&module, &trusted).unwrap();
    let spec = GuestSpec { module: module.clone(), trust_roots: vec![root], grants: vec!["endpoint".into()], ..Default::default() };
    let hot = HotGuest::build(&build("toy", &spec)).unwrap();
    let serving = hot.current().digest_hex().to_string();

    // The new build, in place, signed by the wrong key.
    let v2 = toy_variant(&dir, "staging.wasm", "v2");
    std::fs::copy(&v2, &module).unwrap();
    sign::sign_module_file(&module, &stranger).unwrap();
    assert!(!hot.is_current_for(&build("toy", &spec)).unwrap(), "the bytes on disk changed");
    let err = hot.replace(&build("toy", &spec)).unwrap_err();
    assert!(err.contains("did not verify") && err.contains("[products.toy]"), "{err}");
    assert_eq!(hot.current().digest_hex(), serving, "a refused replacement swapped something in");
    assert!(plan(&hot.current()).is_ok(), "the old guest stopped answering");

    // No signature at all: the same.
    let _ = std::fs::remove_file(dir.join("toy.wasm.p7s"));
    assert!(hot.replace(&build("toy", &spec)).unwrap_err().contains("toy.wasm.p7s"));
    assert_eq!(hot.current().digest_hex(), serving);

    // Signed by the trusted key: accepted, live.
    sign::sign_module_file(&module, &trusted).unwrap();
    let r = hot.replace(&build("toy", &spec)).unwrap();
    assert_eq!((r.from_digest.as_str(), r.to_digest.as_str(), r.signer.as_deref()), (serving.as_str(), digest_of(&v2).as_str(), Some("release-door")));
    let _ = std::fs::remove_dir_all(&dir);
}

/// **New bytes under an old pin are refused**: the block still pins the old
/// digest, so the module on disk is not what the operator vouched for.
#[test]
fn new_bytes_under_the_old_pin_are_refused_and_the_old_guest_keeps_serving() {
    let dir = scratch("hot-pin");
    let module = toy_copy(&dir);
    let spec = toy_spec(&module);
    let hot = HotGuest::build(&build("toy", &spec)).unwrap();
    let serving = hot.current().digest_hex().to_string();
    std::fs::copy(toy_variant(&dir, "v2.wasm", "v2"), &module).unwrap();
    let err = hot.replace(&build("toy", &spec)).unwrap_err();
    assert!(err.contains("not the bytes the operator vouched for"), "{err}");
    assert_eq!(hot.current().digest_hex(), serving);
    assert!(plan(&hot.current()).is_ok());
    let _ = std::fs::remove_dir_all(&dir);
}