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 ¤t, 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, ¤t, 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, ¤t, &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, ¤t, 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 ¤t, 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, ¤t, &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, ¤t, 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 ¤t, 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, ¤t, 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 ¤t, 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, ¤t, 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, ¤t, 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 }