From 6866d755364b7af08016196df1bcbfe02608bda3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 19:10:58 +0200 Subject: [PATCH] idunn-launch tests: lease gate, foreign lease, lease loss, stateless, route challenge, verbatim splice, closed placeholders, child exit (unbuilt) Co-Authored-By: Claude Sonnet 5.5 --- src/launcher.rs | 713 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 713 insertions(+) diff --git a/src/launcher.rs b/src/launcher.rs index f3bee58..25e38db 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -1275,3 +1275,716 @@ fn start_child( .spawn(&contract.release_root.join(&contract.child), &args) .ok() } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::{AtomicU64, AtomicUsize}; + + use cultcache_rs::{CultCacheEnvelope, TryCompareExchangeSnapshotOutcome}; + use cultnet_rs::{ + IdunnExpectedCapability, IdunnExpectedRoute, IdunnRuntimeActivationLaunch, + IdunnServiceIdentity, RuntimePresenceAuthenticationContext, VerifiedRuntimeAuthority, + authenticate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, + }; + + use super::*; + use crate::drivers::{WriteLeasePort, exact_route_snapshot_response, request_tcp_snapshot}; + + fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) + } + + struct Fixture { + dir: tempfile::TempDir, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + activation_credential: Vec, + provider_credential: Vec, + verified: VerifiedRuntimeAuthority, + } + + fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec) { + let envelope = CultCacheEnvelope { + key: key.into(), + r#type: kind.into(), + payload, + stored_at: chrono::Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let outcome = SingleFileMessagePackBackingStore::new(path) + .try_compare_exchange_snapshot(&[], &[envelope]) + .unwrap(); + assert!(matches!( + outcome, + TryCompareExchangeSnapshotOutcome::Exchanged + )); + } + + fn fixture(lease_required: bool) -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let provider_path = root.join("provider.cc"); + let provider = + enroll_service_identity_at::(&provider_path).unwrap(); + let idunn = + enroll_service_identity_at::(&root.join("idunn.cc")).unwrap(); + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: "synapse".into(), + plan_id: digest('1'), + incarnation_id: "incarnation-1".into(), + sealed_release_id: digest('2'), + source_repository: "github.com/GameCult/gamecult-chat".into(), + source_revision: "3".repeat(40), + recipe_sha256: digest('4'), + runtime_id: "synapse-yggdrasil".into(), + expected_signer_identity_id: provider.entry().identity_id.clone(), + health_contract: "gamecult.chat.synapse.presence-launcher.v1".into(), + artifact_sha256: digest('5'), + state_schema_generation: lease_required.then(|| "state-v1".into()), + state_contract_sha256: lease_required.then(|| digest('6')), + write_lease_required: lease_required, + route: Some(IdunnExpectedRoute { + route_id: "synapse-tcp".into(), + transport: "tcp".into(), + stable_endpoint: "tcp://127.0.0.1:18834".into(), + candidate_endpoint: "tcp://127.0.0.1:18835".into(), + }), + capabilities: vec![IdunnExpectedCapability { + capability: "gamecult.chat.homeserver".into(), + schema: "matrix.client.v3".into(), + compatibility: "v3".into(), + minimum_capacity: 1, + }], + dependencies: Vec::new(), + }; + let launch = + IdunnRuntimeActivationLaunch::issue(&expected, digest('7'), 100, &idunn).unwrap(); + let activation = launch.activation().clone(); + let mut activation_credential = Vec::new(); + launch.write_credential(&mut activation_credential).unwrap(); + let verified = verify_runtime_authority( + &expected, + &activation, + &idunn.trust_anchor().unwrap(), + &provider.entry().public_key, + ) + .unwrap(); + let bundle = root.join("bundle"); + fs::create_dir(&bundle).unwrap(); + write_record( + &bundle.join("expected.cc"), + &expected.target, + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + expected.canonical_bytes().unwrap(), + ); + write_record( + &bundle.join("activation.cc"), + &expected.target, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + activation.canonical_bytes().unwrap(), + ); + let provider_credential = fs::read(&provider_path).unwrap(); + for directory in ["release/bin", "tmp"] { + fs::create_dir_all(root.join(directory)).unwrap(); + } + Fixture { + dir, + expected, + activation, + activation_credential, + provider_credential, + verified, + } + } + + impl Fixture { + fn authority(&self) -> RuntimeAuthority { + RuntimeAuthority::open( + &self.dir.path().join("bundle"), + &self.activation_credential[..], + &self.provider_credential[..], + ) + .unwrap() + } + + fn lease(&self, warming_sha256: &str) -> IdunnProcessWriteLeaseRecord { + IdunnProcessWriteLeaseRecord { + schema_version: cultnet_rs::IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: self.expected.target.clone(), + expected_projection_sha256: self.expected.canonical_sha256().unwrap(), + plan_id: self.expected.plan_id.clone(), + incarnation_id: self.expected.incarnation_id.clone(), + sealed_release_id: self.expected.sealed_release_id.clone(), + activation_witness_sha256: self.activation.canonical_sha256().unwrap(), + state_schema_generation: self.expected.state_schema_generation.clone().unwrap(), + state_contract_sha256: self.expected.state_contract_sha256.clone().unwrap(), + runtime_id: self.expected.runtime_id.clone(), + runtime_instance_id: self.activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_sha256.into(), + lease_epoch: 1, + issued_at_unix_millis: 200, + } + } + + fn contract(&self, template: &str) -> LaunchContract { + fs::write(self.dir.path().join("release/config.tmpl"), template).unwrap(); + LaunchContract { + release_root: self.dir.path().join("release"), + child: PathBuf::from("bin/child"), + child_args: vec!["--serve".into()], + config_flag: Some("--config".into()), + template: PathBuf::from("config.tmpl"), + listener: ChildListener::UnixSocket, + health_path: "/health".into(), + health_contract: self.expected.health_contract.clone(), + state_root: Some(PathBuf::from("/var/lib/synapse")), + lease_wait: Duration::from_secs(3600), + startup: Duration::from_secs(3600), + heartbeat: Duration::from_secs(5), + } + } + } + + /// Virtual time runs a hundred times faster than the wall clock. + struct FastClock(AtomicU64); + + impl Clock for FastClock { + fn now_millis(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } + + fn sleep(&self, duration: Duration) { + self.0 + .fetch_add(duration.as_millis() as u64, Ordering::SeqCst); + thread::sleep(Duration::from_millis(1)); + } + } + + #[derive(Default)] + struct Sink(Mutex>); + + impl PresenceSink for Sink { + fn put(&self, record: &GameCultRuntimePresenceHealthRecord) -> Result<()> { + self.0.lock().unwrap().push(record.clone()); + Ok(()) + } + } + + impl Sink { + fn states(&self) -> Vec { + self.0 + .lock() + .unwrap() + .iter() + .map(|record| record.state.clone()) + .collect() + } + + fn first(&self, state: &str) -> Option { + self.0 + .lock() + .unwrap() + .iter() + .find(|record| record.state == state) + .cloned() + } + } + + #[derive(Default)] + struct FakeLease(Mutex>); + + impl LeaseReader for FakeLease { + fn read(&self) -> Result> { + Ok(self.0.lock().unwrap().clone()) + } + + fn hold_shared(&self) -> Result> { + Ok(Box::new(())) + } + } + + struct Health(AtomicBool); + + impl HealthProbe for Health { + fn healthy(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + } + + #[derive(Default)] + struct Child { + spawns: AtomicUsize, + exit: Mutex>, + terminated: AtomicBool, + args: Mutex>>, + } + + struct FakeChild(Arc); + + impl ChildHandle for FakeChild { + fn exited(&mut self) -> Option { + *self.0.exit.lock().unwrap() + } + + fn terminate(&mut self, _grace: Duration) { + self.0.terminated.store(true, Ordering::SeqCst); + *self.0.exit.lock().unwrap() = Some(143); + } + } + + struct FakeSpawner(Arc); + + impl ChildSpawner for FakeSpawner { + fn spawn(&self, _program: &Path, args: &[String]) -> Result> { + self.0.spawns.fetch_add(1, Ordering::SeqCst); + self.0.args.lock().unwrap().push(args.to_vec()); + Ok(Box::new(FakeChild(Arc::clone(&self.0)))) + } + } + + struct Credentials(BTreeMap>); + + impl CredentialSource for Credentials { + fn contents(&self, name: &str) -> Result> { + self.0.get(name).cloned().ok_or_else(|| anyhow!("unbound")) + } + + fn path(&self, name: &str) -> Result { + self.0 + .contains_key(name) + .then(|| PathBuf::from(format!("/run/credentials/unit/{name}"))) + .ok_or_else(|| anyhow!("unbound")) + } + } + + struct Run { + sink: Arc, + lease: Arc, + child: Arc, + stop: Arc, + outcome: Option>, + } + + fn start(fx: &Fixture, contract: LaunchContract, healthy: bool) -> Run { + let sink = Arc::new(Sink::default()); + let lease = Arc::new(FakeLease::default()); + let health = Arc::new(Health(AtomicBool::new(healthy))); + let child = Arc::new(Child::default()); + let stop = Arc::new(AtomicBool::new(false)); + let mut secrets = BTreeMap::new(); + secrets.insert("SYNAPSE_KEY".to_owned(), b"s3cret-value\n".to_vec()); + let ports = Ports { + credentials: Arc::new(Credentials(secrets)), + clock: Arc::new(FastClock(AtomicU64::new(1_000_000))), + sink: sink.clone(), + lease: lease.clone(), + spawner: Box::new(FakeSpawner(child.clone())), + health: Box::new(move |_| health.clone()), + }; + let authority = fx.authority(); + let route = TcpListener::bind("127.0.0.1:0").unwrap(); + let tmp = fx.dir.path().join("tmp"); + let flag = stop.clone(); + let outcome = thread::spawn(move || run(&contract, authority, route, &tmp, ports, &flag)); + Run { + sink, + lease, + child, + stop, + outcome: Some(outcome), + } + } + + impl Run { + fn finish(&mut self) -> Outcome { + self.outcome.take().unwrap().join().unwrap() + } + + fn stop(&mut self) -> Outcome { + self.stop.store(true, Ordering::SeqCst); + self.finish() + } + } + + fn wait_until(what: &str, condition: impl Fn() -> bool) { + for _ in 0..4000 { + if condition() { + return; + } + thread::sleep(Duration::from_millis(5)); + } + panic!("timed out waiting for {what}"); + } + + #[test] + fn stateful_child_never_starts_before_its_lease() { + let fx = fixture(true); + let mut launch = start(&fx, fx.contract("listen {{child.listen}}"), true); + wait_until("two warming heartbeats", || { + launch.sink.states().iter().filter(|s| *s == "warming").count() >= 2 + }); + assert_eq!(launch.child.spawns.load(Ordering::SeqCst), 0); + let warming = launch.sink.first("warming").unwrap(); + assert!(warming.write_lease_sha256.is_none()); + + // The lease binds the launcher's own Warming proof; only then it starts. + let lease = fx.lease(&warming.canonical_sha256().unwrap()); + *launch.lease.0.lock().unwrap() = Some(lease.clone()); + wait_until("child spawn", || { + launch.child.spawns.load(Ordering::SeqCst) == 1 + }); + wait_until("active", || launch.sink.first("active").is_some()); + let active = launch.sink.first("active").unwrap(); + assert_eq!( + active.write_lease_sha256.as_deref(), + Some(lease.canonical_sha256().unwrap().as_str()) + ); + assert_eq!(launch.stop(), Outcome::Stopped); + assert!(launch.child.terminated.load(Ordering::SeqCst)); + } + + #[test] + fn a_lease_for_another_activation_or_expected_is_not_accepted() { + let fx = fixture(true); + let foreign: [(&str, fn(&mut IdunnProcessWriteLeaseRecord)); 3] = [ + ("another runtime instance", |lease| { + lease.runtime_instance_id = "other".into() + }), + ("another Expected", |lease| { + lease.expected_projection_sha256 = digest('9') + }), + ("another warming proof", |lease| { + lease.warming_presence_sha256 = digest('9') + }), + ]; + for (name, edit) in foreign { + let mut contract = fx.contract("x"); + contract.lease_wait = Duration::from_secs(5); + let mut launch = start(&fx, contract, true); + // Installed at once; the launcher's own proofs are never digest('8'). + let mut lease = fx.lease(&digest('8')); + edit(&mut lease); + *launch.lease.0.lock().unwrap() = Some(lease); + assert_eq!(launch.finish(), Outcome::Failed("lease-timeout"), "{name}"); + assert_eq!(launch.child.spawns.load(Ordering::SeqCst), 0, "{name}"); + assert_eq!(launch.sink.states().last().unwrap(), "failed", "{name}"); + } + } + + #[test] + fn lease_loss_stops_the_child() { + let fx = fixture(true); + let mut launch = start(&fx, fx.contract("x"), true); + wait_until("warming", || launch.sink.first("warming").is_some()); + let warming = launch.sink.first("warming").unwrap(); + *launch.lease.0.lock().unwrap() = Some(fx.lease(&warming.canonical_sha256().unwrap())); + wait_until("active", || launch.sink.first("active").is_some()); + *launch.lease.0.lock().unwrap() = None; + let outcome = launch.finish(); + assert_eq!(outcome, Outcome::Failed("lease-lost")); + assert_ne!(outcome.exit_code(), 0); + assert!(launch.child.terminated.load(Ordering::SeqCst)); + assert_eq!(launch.sink.states().last().unwrap(), "failed"); + } + + #[test] + fn stateless_child_starts_without_a_lease() { + let fx = fixture(false); + let mut launch = start(&fx, fx.contract("x"), true); + wait_until("active", || launch.sink.first("active").is_some()); + assert_eq!(launch.child.spawns.load(Ordering::SeqCst), 1); + assert_eq!(launch.sink.states().first().unwrap(), "warming"); + assert!( + launch + .sink + .first("active") + .unwrap() + .write_lease_sha256 + .is_none() + ); + launch.stop(); + } + + #[test] + fn child_exit_ends_the_launcher() { + for code in [0, 3] { + let fx = fixture(false); + let mut launch = start(&fx, fx.contract("x"), true); + wait_until("active", || launch.sink.first("active").is_some()); + *launch.child.exit.lock().unwrap() = Some(code); + let outcome = launch.finish(); + assert_eq!(outcome, Outcome::Failed("child-exited"), "exit {code}"); + assert_eq!(outcome.exit_code(), 1); + assert_eq!(launch.sink.states().last().unwrap(), "failed"); + } + } + + #[test] + fn closed_placeholder_set_renders_and_refuses() { + let fx = fixture(false); + let template = "listen={{child.listen}}\nroot={{state_root}}\nkey={{credential:SYNAPSE_KEY}}\npath={{credential_path:SYNAPSE_KEY}}\n"; + let mut launch = start(&fx, fx.contract(template), true); + wait_until("spawn", || launch.child.spawns.load(Ordering::SeqCst) == 1); + let args = launch.child.args.lock().unwrap()[0].clone(); + assert_eq!(args[..2], ["--serve", "--config"]); + let config = PathBuf::from(&args[2]); + assert_eq!(config.parent().unwrap(), fx.dir.path().join("tmp")); + assert_eq!( + fs::metadata(&config).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let rendered = fs::read_to_string(&config).unwrap(); + let socket = fx.dir.path().join("tmp/child.sock"); + assert_eq!( + rendered, + format!( + "listen={}\nroot=/var/lib/synapse\nkey=s3cret-value\npath=/run/credentials/unit/SYNAPSE_KEY\n", + socket.display() + ) + ); + assert!( + !rendered.contains("18835"), + "the candidate bind never reaches the child" + ); + launch.stop(); + + // Any other placeholder refuses to start, and the refusal names no input. + let fx = fixture(false); + let mut launch = start(&fx, fx.contract("a={{canary_bind}}"), true); + assert_eq!(launch.finish(), Outcome::Failed("child-start-refused")); + assert_eq!(launch.child.spawns.load(Ordering::SeqCst), 0); + let credentials = Credentials(BTreeMap::new()); + let renderer = ConfigRenderer { + credentials: &credentials, + listen: "L", + state_root: None, + }; + for template in [ + "{{canary_bind}}", + "{{credential:CANARY-MISSING}}", + "{{credential:canary b}}", + "{{child.listen}} {{canary", + "{{state_root}}", + ] { + let error = format!("{:#}", renderer.render(template).unwrap_err()); + assert!(!error.to_lowercase().contains("canary"), "{error}"); + } + } + + fn shared(fx: &Fixture, health: Arc) -> Arc { + Arc::new(Shared { + authority: Mutex::new(fx.authority()), + phase: Mutex::new(Phase::Active(None)), + lease: Mutex::new(None), + lease_required: false, + health, + clock: Arc::new(SystemClock), + }) + } + + #[test] + fn route_answer_degrades_with_the_child() { + let fx = fixture(false); + let health = Arc::new(Health(AtomicBool::new(true))); + let shared = shared(&fx, health.clone()); + let state_of = |shared: &Shared| { + let request = CultNetMessage::SnapshotRequest { + message_id: "challenge-1".into(), + schema_ids: None, + record_keys: None, + }; + let CultNetMessage::SnapshotResponseRaw { documents, .. } = + shared.observe(request).unwrap() + else { + panic!("not a raw snapshot response") + }; + let record: GameCultRuntimePresenceHealthRecord = + rmp_serde::from_slice(&documents[0].payload).unwrap(); + assert_eq!(record.detail, "route-observation:challenge-1"); + record.state + }; + assert_eq!(state_of(&shared), "active"); + health.0.store(false, Ordering::SeqCst); + assert_eq!(state_of(&shared), "degraded"); + } + + #[test] + fn route_challenge_is_answered_with_both_signatures_through_idunns_client() { + let fx = fixture(false); + let shared = shared(&fx, Arc::new(Health(AtomicBool::new(true)))); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let responder: Responder = Arc::new(move |request| shared.observe(request)); + let halt = Arc::new(AtomicBool::new(false)); + let acceptor = RouteAcceptor::new( + listener, + ChildEndpoint::Unix("/nonexistent".into()), + responder, + ); + let serving = { + let halt = halt.clone(); + thread::spawn(move || acceptor.serve(halt)) + }; + let request = CultNetMessage::SnapshotRequest { + message_id: "challenge-7".into(), + schema_ids: Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), + record_keys: Some(vec![fx.expected.target.clone()]), + }; + let payload = + encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0).unwrap(); + let response = request_tcp_snapshot(address, &payload).ok().unwrap(); + let message = + decode_cultnet_message_from_slice(&response, CultNetWireContract::CultNetSchemaV0) + .unwrap(); + let answer = + exact_route_snapshot_response(message, "challenge-7", &fx.expected.target).unwrap(); + let record: GameCultRuntimePresenceHealthRecord = + rmp_serde::from_slice(&answer.canonical_presence).unwrap(); + let claim = authenticate_runtime_presence_claim( + &answer.canonical_presence, + &fx.verified, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: record.observed_at_unix_millis, + maximum_age_millis: 10_000, + maximum_future_skew_millis: 10, + }, + ) + .unwrap(); + assert_eq!(claim.record().state, "active"); + halt.store(true, Ordering::SeqCst); + serving.join().unwrap().unwrap(); + } + + #[test] + fn member_bytes_reach_the_child_verbatim_and_only_challenge_frames_are_answered() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("child.sock"); + let child = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let request: Vec = + b"POST /_matrix/x HTTP/1.1\r\nContent-Length: 6\r\n\r\n\x00\x01\x00\x02\x00\x03".to_vec(); + let response: Vec = + b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n\x00\xff\x00\x10".to_vec(); + let received = Arc::new(Mutex::new(Vec::new())); + let connections = Arc::new(AtomicUsize::new(0)); + let fake = { + let (received, connections, response, want) = ( + received.clone(), + connections.clone(), + response.clone(), + request.len(), + ); + thread::spawn(move || { + for stream in child.incoming() { + let mut stream = stream.unwrap(); + connections.fetch_add(1, Ordering::SeqCst); + let mut buffer = vec![0_u8; want]; + if stream.read_exact(&mut buffer).is_err() { + continue; + } + *received.lock().unwrap() = buffer; + stream.write_all(&response).unwrap(); + return; + } + }) + }; + let challenges = Arc::new(AtomicUsize::new(0)); + let responder: Responder = { + let challenges = challenges.clone(); + Arc::new(move |_| { + challenges.fetch_add(1, Ordering::SeqCst); + bail!("not under test") + }) + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let halt = Arc::new(AtomicBool::new(false)); + let acceptor = RouteAcceptor::new(listener, ChildEndpoint::Unix(socket), responder); + let serving = { + let halt = halt.clone(); + thread::spawn(move || acceptor.serve(halt)) + }; + + // A leading 0x00 that is not a challenge frame never reaches the child. + let mut probe = TcpStream::connect(address).unwrap(); + probe.write_all(&[0x00, 0xff, 0xff, 0xff, 1, 2, 3]).unwrap(); + let mut answered = Vec::new(); + let _ = probe.read_to_end(&mut answered); + assert!(answered.is_empty()); + assert_eq!(connections.load(Ordering::SeqCst), 0); + + // Everything else is copied untouched, both ways, 0x00 bytes included. + let mut member = TcpStream::connect(address).unwrap(); + member.write_all(&request).unwrap(); + let mut answer = Vec::new(); + member.read_to_end(&mut answer).unwrap(); + assert_eq!(answer, response); + fake.join().unwrap(); + assert_eq!(*received.lock().unwrap(), request); + assert_eq!( + challenges.load(Ordering::SeqCst), + 0, + "a malformed frame fails before the responder; member traffic never reaches it" + ); + halt.store(true, Ordering::SeqCst); + serving.join().unwrap().unwrap(); + } + + #[test] + fn contract_parsing_refuses_escapes_and_echoes_nothing() { + let base = |extra: &[&str]| -> Result { + let mut args: Vec = [ + "--release-root", + "/r", + "--child", + "bin/c", + "--template", + "t", + "--listener", + "unix", + "--health-path", + "/health", + "--health-contract", + "h", + ] + .map(String::from) + .to_vec(); + args.extend(extra.iter().map(|value| value.to_string())); + LaunchContract::parse(args) + }; + assert!(base(&["--child-arg", "--flag-looking-value"]).is_ok()); + assert!(base(&["--child", "../escape"]).is_err()); + assert!(base(&["--template", "/etc/passwd"]).is_err()); + let error = base(&["--canary-flag", "canary-value"]) + .unwrap_err() + .to_string(); + assert!(!error.contains("canary")); + } + + #[test] + fn real_lease_reader_holds_the_shared_lock_idunn_must_respect() { + let fx = fixture(true); + let path = fx.dir.path().join("lease.cc"); + let warming = digest('a'); + let lease = fx.lease(&warming); + write_record( + &path, + &lease.target, + IdunnProcessWriteLeaseRecord::TYPE, + cultnet_rs::IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + lease.canonical_bytes().unwrap(), + ); + let reader: Arc = Arc::new(DriverLeaseReader::new(&lease.target, &path)); + let hold = LeaseHold::acquire(reader, &fx.expected, &fx.activation, &[warming]).unwrap(); + assert!(hold.still_current()); + let idunn = CultCacheWriteLeaseDriver::new(&lease.target, &path); + let denial = idunn.revoke_exact(Some(&lease)).unwrap_err(); + assert!(denial.to_string().contains("shared-lock consumer")); + drop(hold); + idunn.revoke_exact(Some(&lease)).unwrap(); + } +}