rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit c8e0a9b614a8bc6a725028ed8df5a0c5c0477fb9
parent ccee5a9ecd935fe4bd40a30e3d5eb584ef55b715
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 23:43:01 +0000

test(rhi): close foundation integration wave

Diffstat:
Msrc/identity_envelope.rs | 2++
Msrc/runtime_adapters.rs | 40++++++++++++++++++++++++++++++++++++++--
Mtests/services_hardening_config_lifecycle.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_runtime_foundation.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/services_hardening_state_resilience.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 260 insertions(+), 3 deletions(-)

diff --git a/src/identity_envelope.rs b/src/identity_envelope.rs @@ -1242,6 +1242,7 @@ mod tests { candidate } + #[cfg(any(target_os = "linux", target_os = "macos"))] fn different_identity_secret() -> [u8; 32] { let mut candidate = bytes("radroots.rhi.test-only.different-identity-secret.v1"); while SecretKey::from_slice(&candidate).is_err() { @@ -1295,6 +1296,7 @@ mod tests { (configuration, metadata) } + #[cfg(any(target_os = "linux", target_os = "macos"))] fn binding(root: &Path, path: &Path) -> RhiIdentityEnvelopeBinding { let (configuration, metadata) = binding_authority(root, path); RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata) diff --git a/src/runtime_adapters.rs b/src/runtime_adapters.rs @@ -447,8 +447,8 @@ mod tests { }; use radroots_service_host::{ - EntropyError, HostError, MonotonicClockError, TaskClassification, TaskMetadata, TaskName, - WallClockError, + EntropyError, HostError, MonotonicClockError, ShutdownPhase, TaskClassification, + TaskMetadata, TaskName, WallClockError, }; use radroots_transport::{ BoxFuture, DeliveryReceipt, DeliveryRequest, EventSubscription, FetchPage, FetchRequest, @@ -729,6 +729,42 @@ mod tests { ); } + #[tokio::test] + async fn adapter_shutdown_cancels_and_joins_long_lived_tasks() { + let transport = Arc::new(NoIoTransport); + let transports = RhiTransportAdapters::new(transport.clone(), transport.clone(), transport); + let mut adapters = RhiRuntimeAdapters::new( + RhiTimeEntropyAdapters::new( + FixedWall(Ok(UnixTimeSeconds::new(1))), + FixedMonotonic(MonotonicTime::from_duration_since_origin(Duration::ZERO)), + FixedEntropy(Ok(1)), + ), + transports, + RhiIdentityCredentialAdapters::canonical(), + ); + let cancellations = Arc::new(AtomicUsize::new(0)); + let task_cancellations = Arc::clone(&cancellations); + adapters + .supervisor_mut() + .spawn( + TaskMetadata::new( + TaskName::new("adapter_cancellation_test").expect("task name"), + TaskClassification::Critical, + Some(ShutdownPhase::CloseNetwork), + ) + .expect("metadata"), + move |cancel| async move { + cancel.cancelled().await; + task_cancellations.fetch_add(1, Ordering::Relaxed); + Ok::<(), HostError>(()) + }, + ) + .expect("register"); + adapters.shutdown().await.expect("joined shutdown"); + assert_eq!(cancellations.load(Ordering::Relaxed), 1); + assert_eq!(adapters.supervised_task_count(), 0); + } + #[test] fn all_error_codes_messages_and_debug_are_stable() { let cases = [ diff --git a/tests/services_hardening_config_lifecycle.rs b/tests/services_hardening_config_lifecycle.rs @@ -7,6 +7,7 @@ use std::{ path::{Path, PathBuf}, }; +use nostr::{Keys, SecretKey}; use radroots_service_sqlite::{ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database, @@ -309,6 +310,76 @@ async fn interrupted_first_binding_resumes_after_schema_migration() { connection.close().await.expect("connection close"); } +#[tokio::test] +async fn semantically_conflicting_but_structurally_valid_history_fails_closed() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + fs::create_dir_all(runtime.context().paths().state()).expect("state directory"); + fs::set_permissions( + runtime.context().paths().state(), + fs::Permissions::from_mode(0o700), + ) + .expect("state mode"); + let current = configuration(EXAMPLE); + let metadata = RhiStateMetadata::new( + &runtime, + &current, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) + .expect("metadata"); + let (applied_at, build) = evidence(1_725_000_000); + initialize_rhi_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialize"); + + let conflicting_key = + Keys::new(SecretKey::from_slice(&[0x33; 32]).expect("deterministic conflicting secret")) + .public_key() + .to_hex(); + assert_ne!(conflicting_key, metadata.expected_identity().as_hex()); + let mut connection = offline_connection(&runtime).await; + sqlx::query( + r#"INSERT INTO rhi_config_bindings ( + generation, normalized_config_sha256, evidence_policy_sha256, + service_public_key, config_contract_version, state_contract_version, + admin_contract_version, status_contract_version, provider_contract_version, + applied_at_unix_s, service_version, service_commit, lib_revision, + rust_version, target, feature_profile + ) + SELECT generation + 1, normalized_config_sha256, evidence_policy_sha256, + ?, config_contract_version, state_contract_version, + admin_contract_version, status_contract_version, provider_contract_version, + applied_at_unix_s + 1, service_version, service_commit, lib_revision, + rust_version, target, feature_profile + FROM rhi_config_bindings WHERE generation = 1"#, + ) + .bind(conflicting_key) + .execute(&mut connection) + .await + .expect("append structurally valid conflicting evidence"); + connection.close().await.expect("connection close"); + + let rejected = open_rhi_state_read_write_from_config(&runtime, &current, applied_at, &build) + .await + .expect_err("conflicting history must fail closed"); + assert_eq!(rejected.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence); + let retried = open_rhi_state_read_write_from_config(&runtime, &current, applied_at, &build) + .await + .expect_err("rejected history must not leak writer authority"); + assert_eq!(retried.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence); + + let mut connection = offline_connection(&runtime).await; + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rhi_config_bindings") + .fetch_one(&mut connection) + .await + .expect("history count"), + 2 + ); + connection.close().await.expect("connection close"); +} + #[test] fn configuration_lifecycle_surface_is_sealed_and_redacted() { assert!(HOST_SOURCE.contains("open_read_write_existing_with_intent")); diff --git a/tests/services_hardening_runtime_foundation.rs b/tests/services_hardening_runtime_foundation.rs @@ -264,7 +264,7 @@ async fn foundation_proves_state_config_identity_and_contacts_no_transport() { } #[tokio::test] -async fn missing_or_mismatched_state_fails_before_identity_or_transport_access() { +async fn missing_state_fails_before_identity_or_transport_access() { let directory = tempfile::tempdir().expect("root"); let runtime = runtime(directory.path()); let secret = identity_secret(); @@ -294,6 +294,86 @@ async fn missing_or_mismatched_state_fails_before_identity_or_transport_access() assert!(!runtime.artifacts().state_database().exists()); } +#[tokio::test] +async fn mismatched_configuration_fails_before_identity_or_transport_access() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + let secret = identity_secret(); + let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret")) + .public_key() + .to_hex(); + let current = configuration(&runtime, &identity); + let metadata = prepare(&runtime, &current); + let (applied_at, build) = evidence(); + initialize_rhi_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialize"); + + let changed_source = CONFIG_EXAMPLE + .replace( + "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", + runtime.identity_path().to_str().expect("identity path"), + ) + .replace(&"2".repeat(64), &identity) + .replacen("level = \"info\"", "level = \"debug\"", 1); + let changed = parse_rhi_config_v1(changed_source.as_bytes(), RhiConfigProfile::RepoLocal) + .expect("changed configuration"); + let spy = TransportSpy(Arc::new(AtomicUsize::new(0))); + let error = + open_rhi_runtime_foundation(runtime.clone(), changed, adapters(&spy), applied_at, &build) + .await + .expect_err("configuration mismatch"); + assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::StateOpen); + assert_eq!(spy.invoked(), 0); + + let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("failed foundation releases state authority"); + reopened.close().await.expect("close"); +} + +#[tokio::test] +async fn identity_failure_after_state_open_releases_authority_without_transport_access() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + let secret = identity_secret(); + let identity = Keys::new(SecretKey::from_slice(&secret).expect("secret")) + .public_key() + .to_hex(); + let configuration = configuration(&runtime, &identity); + let metadata = prepare(&runtime, &configuration); + let (applied_at, build) = evidence(); + initialize_rhi_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialize"); + fs::remove_file( + runtime + .context() + .paths() + .secrets() + .join("service_wrapping_key"), + ) + .expect("remove credential"); + + let spy = TransportSpy(Arc::new(AtomicUsize::new(0))); + let error = open_rhi_runtime_foundation( + runtime.clone(), + configuration, + adapters(&spy), + applied_at, + &build, + ) + .await + .expect_err("missing credential"); + assert_eq!(error.kind(), RhiRuntimeFoundationErrorKind::IdentityAccess); + assert_eq!(spy.invoked(), 0); + + let reopened = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("identity failure releases state authority"); + reopened.close().await.expect("close"); +} + #[test] fn source_and_contract_keep_final_runtime_authority_deferred() { let contract: serde_json::Value = serde_json::from_str(CONTRACT_SOURCE).expect("contract"); diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs @@ -7,6 +7,7 @@ use std::{ num::NonZeroU64, os::unix::fs::{MetadataExt, PermissionsExt}, path::{Path, PathBuf}, + time::Duration, }; use radroots_service_sqlite::{ @@ -96,6 +97,21 @@ fn recovery_paths(runtime: &rhi::RhiRuntimeContext) -> [PathBuf; 4] { ] } +fn directory_inventory(directory: &Path) -> Vec<String> { + let mut entries = fs::read_dir(directory) + .expect("state directory") + .map(|entry| { + entry + .expect("state entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::<Vec<_>>(); + entries.sort(); + entries +} + #[tokio::test] async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() { let directory = tempfile::tempdir().expect("temporary root"); @@ -110,6 +126,36 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() { let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) .await .expect("writable host"); + let cancelled_bundle = directory.path().join("cancelled-backup"); + let cancelled = tokio::time::timeout( + Duration::from_nanos(1), + writer.capture_online_backup( + &cancelled_bundle, + BackupCreatedAtUnixMs::new(1_725_000_000_050).expect("capture time"), + ), + ) + .await; + assert!(cancelled.is_err(), "capture future must be cancellable"); + writer + .close() + .await + .expect("close drains cancelled capture cleanup"); + assert!(!cancelled_bundle.exists()); + + let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writer reacquisition after cancelled capture"); + let cancelled_integrity = tokio::time::timeout( + Duration::from_nanos(1), + writer.inspect_integrity( + IntegrityCheckedAtUnixMs::new(1_725_000_000_099).expect("inspection time"), + ), + ) + .await; + assert!( + cancelled_integrity.is_err(), + "integrity future must be cancellable" + ); let report = writer .inspect_integrity( IntegrityCheckedAtUnixMs::new(1_725_000_000_100).expect("inspection time"), @@ -150,6 +196,13 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() { let live_path = runtime.artifacts().state_database(); let old_live_inode = fs::metadata(live_path).expect("live metadata").ino(); + let state_directory = runtime.context().paths().state(); + let live_bytes_before_inspection = fs::read(live_path).expect("live bytes"); + let live_modified_before_inspection = fs::metadata(live_path) + .expect("live metadata") + .modified() + .expect("live modified time"); + let inventory_before_inspection = directory_inventory(state_directory); let inspection = open_rhi_state_inspection(&runtime, &metadata) .await .expect("read-only inspection"); @@ -188,6 +241,21 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_rhi_authority() { .expect_err("offline staging rejects a live inspection host"); assert_eq!(contended.kind(), RhiStateMaintenanceErrorKind::Authority); inspection.close().await.expect("inspection close"); + assert_eq!( + fs::read(live_path).expect("live bytes after inspection"), + live_bytes_before_inspection + ); + assert_eq!( + fs::metadata(live_path) + .expect("live metadata after inspection") + .modified() + .expect("live modified time after inspection"), + live_modified_before_inspection + ); + assert_eq!( + directory_inventory(state_directory), + inventory_before_inspection + ); let verified = verify_rhi_state_backup( &manifest_bytes,