rhi

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

services_hardening_config_lifecycle.rs (15155B)


      1 #![forbid(unsafe_code)]
      2 #![cfg(any(target_os = "linux", target_os = "macos"))]
      3 
      4 use std::{fs, os::unix::fs::PermissionsExt, path::Path};
      5 
      6 use nostr::{Keys, SecretKey};
      7 use radroots_service_sqlite::{
      8     MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
      9     ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqliteInitializer,
     10     ServiceSqliteInitializerFuture, ServiceSqlitePaths, initialize_database,
     11 };
     12 use radroots_storage::event::SourceGeneration;
     13 use rhi::{
     14     RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigApplyErrorKind,
     15     RhiConfigProfile, RhiStateMetadata, apply_rhi_configuration, initialize_rhi_state,
     16     open_rhi_state_read_write_from_config, parse_rhi_cli_v1_from, parse_rhi_config_v1,
     17     resolve_rhi_runtime_context, rhi_migration_catalog, rhi_schema_catalog,
     18 };
     19 use sqlx::{ConnectOptions, Connection, Row, SqliteConnection, sqlite::SqliteConnectOptions};
     20 
     21 const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
     22 const CONFIG_SOURCE: &str = include_str!("../src/state_config.rs");
     23 const HOST_SOURCE: &str = include_str!("../src/state_host.rs");
     24 
     25 fn runtime(root: &Path) -> rhi::RhiRuntimeContext {
     26     let invocation = parse_rhi_cli_v1_from([
     27         "rhi",
     28         "--profile",
     29         "repo-local",
     30         "--instance",
     31         "primary",
     32         "--repo-local-root",
     33         root.to_str().expect("UTF-8 root"),
     34         "run",
     35     ])
     36     .expect("invocation");
     37     resolve_rhi_runtime_context(
     38         &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
     39         &invocation,
     40     )
     41     .expect("runtime")
     42 }
     43 
     44 fn configuration(source: &str) -> rhi::RhiConfigDocumentV1 {
     45     parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("configuration")
     46 }
     47 
     48 fn evidence(at: u64) -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) {
     49     let applied_at = MigrationAppliedAtUnixSeconds::new(at).expect("time");
     50     let build = MigrationBuildIdentity::new(
     51         env!("CARGO_PKG_VERSION"),
     52         "1111111111111111111111111111111111111111",
     53         "053d0c750bf9cd683c6ea37cefe7e79617ba629f",
     54         "rustc-test",
     55         "test-target",
     56         "service-host",
     57         1,
     58         rhi::RHI_STATE_SCHEMA_VERSION,
     59         1,
     60         1,
     61         1,
     62     )
     63     .expect("build");
     64     (applied_at, build)
     65 }
     66 
     67 async fn offline_connection(runtime: &rhi::RhiRuntimeContext) -> SqliteConnection {
     68     let options = SqliteConnectOptions::new()
     69         .filename(runtime.artifacts().state_database())
     70         .create_if_missing(false)
     71         .disable_statement_logging();
     72     SqliteConnection::connect_with(&options)
     73         .await
     74         .expect("offline connection")
     75 }
     76 
     77 fn initialize_empty_catalog<'a>(
     78     _initializer: &'a mut ServiceSqliteInitializer<'_>,
     79 ) -> ServiceSqliteInitializerFuture<'a, core::convert::Infallible> {
     80     Box::pin(async { Ok(()) })
     81 }
     82 
     83 #[tokio::test]
     84 async fn existing_intent_and_offline_apply_bind_exact_append_only_evidence() {
     85     let directory = tempfile::tempdir().expect("root");
     86     let runtime = runtime(directory.path());
     87     fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
     88     fs::set_permissions(
     89         runtime.context().paths().state(),
     90         fs::Permissions::from_mode(0o700),
     91     )
     92     .expect("state mode");
     93     let current = configuration(EXAMPLE);
     94     let metadata = RhiStateMetadata::new(
     95         &runtime,
     96         &current,
     97         SourceGeneration::new([0x5a; 32]).expect("generation"),
     98         1_725_000_000_000,
     99     )
    100     .expect("metadata");
    101     let (applied_at, build) = evidence(1_725_000_000);
    102     initialize_rhi_state(&runtime, &metadata, applied_at, &build)
    103         .await
    104         .expect("initialize");
    105 
    106     let state = open_rhi_state_read_write_from_config(&runtime, &current, applied_at, &build)
    107         .await
    108         .expect("intent open");
    109     assert_eq!(
    110         state.metadata().database().source_generation(),
    111         metadata.database().source_generation()
    112     );
    113     state.close().await.expect("close");
    114 
    115     let changed_source = EXAMPLE.replacen("level = \"info\"", "level = \"debug\"", 1);
    116     let changed = configuration(&changed_source);
    117     let (second_at, second_build) = evidence(1_725_000_001);
    118     let outcome = apply_rhi_configuration(&runtime, &current, &changed, second_at, &second_build)
    119         .await
    120         .expect("offline apply");
    121     assert_eq!(outcome.generation(), 2);
    122     assert!(outcome.changed());
    123     let replay = apply_rhi_configuration(&runtime, &changed, &changed, second_at, &second_build)
    124         .await
    125         .expect("idempotent replay");
    126     assert_eq!(replay.generation(), 2);
    127     assert!(!replay.changed());
    128 
    129     let old = open_rhi_state_read_write_from_config(&runtime, &current, second_at, &second_build)
    130         .await
    131         .expect_err("stale config must fail closed");
    132     assert_eq!(old.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence);
    133     let accepted =
    134         open_rhi_state_read_write_from_config(&runtime, &changed, second_at, &second_build)
    135             .await
    136             .expect("new config accepted");
    137     accepted.close().await.expect("close");
    138 
    139     let mut connection = offline_connection(&runtime).await;
    140     let rows = sqlx::query(
    141         "SELECT generation, length(normalized_config_sha256) AS config_bytes,
    142          length(evidence_policy_sha256) AS policy_bytes, service_public_key,
    143          state_contract_version, applied_at_unix_s
    144          FROM rhi_config_bindings ORDER BY generation",
    145     )
    146     .fetch_all(&mut connection)
    147     .await
    148     .expect("history");
    149     assert_eq!(rows.len(), 2);
    150     assert_eq!(rows[0].try_get::<i64, _>("generation").unwrap(), 1);
    151     assert_eq!(rows[1].try_get::<i64, _>("generation").unwrap(), 2);
    152     for row in &rows {
    153         assert_eq!(row.try_get::<i64, _>("config_bytes").unwrap(), 32);
    154         assert_eq!(row.try_get::<i64, _>("policy_bytes").unwrap(), 32);
    155         assert_eq!(
    156             row.try_get::<i64, _>("state_contract_version").unwrap(),
    157             i64::from(rhi::RHI_STATE_SCHEMA_VERSION)
    158         );
    159         assert_eq!(
    160             row.try_get::<String, _>("service_public_key")
    161                 .unwrap()
    162                 .len(),
    163             64
    164         );
    165     }
    166     assert!(
    167         rows[1].try_get::<i64, _>("applied_at_unix_s").unwrap()
    168             >= rows[0].try_get::<i64, _>("applied_at_unix_s").unwrap()
    169     );
    170     assert!(
    171         sqlx::query("UPDATE rhi_config_bindings SET generation = generation")
    172             .execute(&mut connection)
    173             .await
    174             .is_err()
    175     );
    176     assert!(
    177         sqlx::query("DELETE FROM rhi_config_bindings")
    178             .execute(&mut connection)
    179             .await
    180             .is_err()
    181     );
    182     connection.close().await.expect("connection close");
    183 
    184     let bytes = fs::read(runtime.artifacts().state_database()).expect("database bytes");
    185     for forbidden in [
    186         directory.path().to_string_lossy().as_bytes(),
    187         b"wss://relay-primary.example".as_slice(),
    188         b"service_wrapping_key".as_slice(),
    189         b"level = \"debug\"".as_slice(),
    190     ] {
    191         assert!(
    192             !bytes
    193                 .windows(forbidden.len())
    194                 .any(|window| window == forbidden)
    195         );
    196     }
    197 }
    198 
    199 #[tokio::test]
    200 async fn apply_requires_current_binding_and_monotonic_time_without_lock_leak() {
    201     let directory = tempfile::tempdir().expect("root");
    202     let runtime = runtime(directory.path());
    203     fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
    204     fs::set_permissions(
    205         runtime.context().paths().state(),
    206         fs::Permissions::from_mode(0o700),
    207     )
    208     .expect("state mode");
    209     let current = configuration(EXAMPLE);
    210     let metadata = RhiStateMetadata::new(
    211         &runtime,
    212         &current,
    213         SourceGeneration::new([0x5a; 32]).expect("generation"),
    214         1_725_000_000_000,
    215     )
    216     .expect("metadata");
    217     let (applied_at, build) = evidence(100);
    218     initialize_rhi_state(&runtime, &metadata, applied_at, &build)
    219         .await
    220         .expect("initialize");
    221     let changed = configuration(&EXAMPLE.replacen("level = \"info\"", "level = \"debug\"", 1));
    222     let (earlier, earlier_build) = evidence(99);
    223     let error = apply_rhi_configuration(&runtime, &current, &changed, earlier, &earlier_build)
    224         .await
    225         .expect_err("time rollback");
    226     assert_eq!(error.kind(), RhiConfigApplyErrorKind::InvalidInput);
    227     let reopened = open_rhi_state_read_write_from_config(&runtime, &current, applied_at, &build)
    228         .await
    229         .expect("authority released after failed apply");
    230     reopened.close().await.expect("close");
    231 }
    232 
    233 #[tokio::test]
    234 async fn interrupted_first_binding_resumes_after_schema_migration() {
    235     let directory = tempfile::tempdir().expect("root");
    236     let runtime = runtime(directory.path());
    237     fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
    238     fs::set_permissions(
    239         runtime.context().paths().state(),
    240         fs::Permissions::from_mode(0o700),
    241     )
    242     .expect("state mode");
    243     let current = configuration(EXAMPLE);
    244     let metadata = RhiStateMetadata::new(
    245         &runtime,
    246         &current,
    247         SourceGeneration::new([0x5a; 32]).expect("generation"),
    248         1_725_000_000_000,
    249     )
    250     .expect("metadata");
    251     let (applied_at, build) = evidence(1_725_000_000);
    252     let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("paths");
    253     let migrations = rhi_migration_catalog().expect("migrations");
    254     let schema = rhi_schema_catalog().expect("schema");
    255     let authority = initialize_database(
    256         &paths,
    257         OpenMode::Initialize,
    258         metadata.initial_database_metadata(),
    259         &schema,
    260         initialize_empty_catalog,
    261     )
    262     .await
    263     .expect("baseline initialize");
    264     let (host, outcome) = ServiceSqliteHost::open_initialized(
    265         &paths,
    266         &metadata.database_identity(),
    267         &migrations,
    268         &schema,
    269         ServiceSqliteConnectionOptions::reviewed(),
    270         authority,
    271         applied_at,
    272         &build,
    273         &[],
    274     )
    275     .await
    276     .expect("schema migration");
    277     assert_eq!(
    278         outcome.initial_version(),
    279         rhi::RHI_STATE_BASE_SCHEMA_VERSION
    280     );
    281     assert_eq!(outcome.final_version(), rhi::RHI_STATE_SCHEMA_VERSION);
    282     host.close().await.expect("close before binding");
    283 
    284     let mut connection = offline_connection(&runtime).await;
    285     let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rhi_config_bindings")
    286         .fetch_one(&mut connection)
    287         .await
    288         .expect("empty binding count");
    289     assert_eq!(count, 0);
    290     connection.close().await.expect("connection close");
    291 
    292     let resumed = open_rhi_state_read_write_from_config(&runtime, &current, applied_at, &build)
    293         .await
    294         .expect("resume first binding");
    295     resumed.close().await.expect("resumed close");
    296     let mut connection = offline_connection(&runtime).await;
    297     let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rhi_config_bindings")
    298         .fetch_one(&mut connection)
    299         .await
    300         .expect("seeded binding count");
    301     assert_eq!(count, 1);
    302     connection.close().await.expect("connection close");
    303 }
    304 
    305 #[tokio::test]
    306 async fn semantically_conflicting_but_structurally_valid_history_fails_closed() {
    307     let directory = tempfile::tempdir().expect("root");
    308     let runtime = runtime(directory.path());
    309     fs::create_dir_all(runtime.context().paths().state()).expect("state directory");
    310     fs::set_permissions(
    311         runtime.context().paths().state(),
    312         fs::Permissions::from_mode(0o700),
    313     )
    314     .expect("state mode");
    315     let current = configuration(EXAMPLE);
    316     let metadata = RhiStateMetadata::new(
    317         &runtime,
    318         &current,
    319         SourceGeneration::new([0x5a; 32]).expect("generation"),
    320         1_725_000_000_000,
    321     )
    322     .expect("metadata");
    323     let (applied_at, build) = evidence(1_725_000_000);
    324     initialize_rhi_state(&runtime, &metadata, applied_at, &build)
    325         .await
    326         .expect("initialize");
    327 
    328     let conflicting_key =
    329         Keys::new(SecretKey::from_slice(&[0x33; 32]).expect("deterministic conflicting secret"))
    330             .public_key()
    331             .to_hex();
    332     assert_ne!(conflicting_key, metadata.expected_identity().as_hex());
    333     let mut connection = offline_connection(&runtime).await;
    334     sqlx::query(
    335         r#"INSERT INTO rhi_config_bindings (
    336             generation, normalized_config_sha256, evidence_policy_sha256,
    337             service_public_key, config_contract_version, state_contract_version,
    338             admin_contract_version, status_contract_version, provider_contract_version,
    339             applied_at_unix_s, service_version, service_commit, lib_revision,
    340             rust_version, target, feature_profile
    341         )
    342         SELECT generation + 1, normalized_config_sha256, evidence_policy_sha256,
    343             ?, config_contract_version, state_contract_version,
    344             admin_contract_version, status_contract_version, provider_contract_version,
    345             applied_at_unix_s + 1, service_version, service_commit, lib_revision,
    346             rust_version, target, feature_profile
    347         FROM rhi_config_bindings WHERE generation = 1"#,
    348     )
    349     .bind(conflicting_key)
    350     .execute(&mut connection)
    351     .await
    352     .expect("append structurally valid conflicting evidence");
    353     connection.close().await.expect("connection close");
    354 
    355     let rejected = open_rhi_state_read_write_from_config(&runtime, &current, applied_at, &build)
    356         .await
    357         .expect_err("conflicting history must fail closed");
    358     assert_eq!(rejected.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence);
    359     let retried = open_rhi_state_read_write_from_config(&runtime, &current, applied_at, &build)
    360         .await
    361         .expect_err("rejected history must not leak writer authority");
    362     assert_eq!(retried.kind(), rhi::RhiStateHostErrorKind::InvalidEvidence);
    363 
    364     let mut connection = offline_connection(&runtime).await;
    365     assert_eq!(
    366         sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rhi_config_bindings")
    367             .fetch_one(&mut connection)
    368             .await
    369             .expect("history count"),
    370         2
    371     );
    372     connection.close().await.expect("connection close");
    373 }
    374 
    375 #[test]
    376 fn configuration_lifecycle_surface_is_sealed_and_redacted() {
    377     assert!(HOST_SOURCE.contains("open_read_write_existing_with_intent"));
    378     assert!(CONFIG_SOURCE.contains("LIMIT 1025"));
    379     assert!(CONFIG_SOURCE.contains("RHI_CONFIG_BINDING_MAX_GENERATIONS"));
    380     for forbidden in [
    381         "pub host:",
    382         "pub transaction:",
    383         "raw_sql",
    384         "rusqlite",
    385         "std::env",
    386     ] {
    387         assert!(!CONFIG_SOURCE.contains(forbidden), "found {forbidden}");
    388     }
    389     for kind in [
    390         RhiConfigApplyErrorKind::InvalidInput,
    391         RhiConfigApplyErrorKind::Binding,
    392         RhiConfigApplyErrorKind::ResourceExhausted,
    393         RhiConfigApplyErrorKind::Transaction,
    394         RhiConfigApplyErrorKind::CommitOutcomeUnknown,
    395         RhiConfigApplyErrorKind::Close,
    396     ] {
    397         let rendered = format!("{kind:?}");
    398         assert!(!rendered.is_empty());
    399     }
    400 }