services_hardening_state_host.rs (24549B)
1 #![forbid(unsafe_code)] 2 #![cfg(any(target_os = "linux", target_os = "macos"))] 3 4 use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path}; 5 6 use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; 7 use radroots_storage::event::SourceGeneration; 8 use rhi::{ 9 RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile, 10 RhiStateHostErrorKind, RhiStateHostMode, RhiStateMetadata, RhiStateRepositoryKind, 11 initialize_rhi_state, open_rhi_state_inspection, open_rhi_state_read_write, 12 parse_rhi_cli_v1_from, parse_rhi_config_v1, resolve_rhi_runtime_context, 13 }; 14 use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions}; 15 16 const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 17 18 const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); 19 const LIB_SOURCE: &str = include_str!("../src/lib.rs"); 20 21 fn runtime(root: &Path, instance: &str) -> rhi::RhiRuntimeContext { 22 let root = root.to_str().expect("UTF-8 temporary root"); 23 let invocation = parse_rhi_cli_v1_from([ 24 "rhi", 25 "--profile", 26 "repo-local", 27 "--instance", 28 instance, 29 "--repo-local-root", 30 root, 31 "run", 32 ]) 33 .expect("valid test invocation"); 34 resolve_rhi_runtime_context( 35 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 36 &invocation, 37 ) 38 .expect("runtime context") 39 } 40 41 fn prepare_state_directory(runtime: &rhi::RhiRuntimeContext) { 42 let directory = runtime.context().paths().state(); 43 fs::create_dir_all(directory).expect("state directory"); 44 fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode"); 45 } 46 47 fn prepare_state_root(runtime: &rhi::RhiRuntimeContext) { 48 let root = runtime 49 .context() 50 .paths() 51 .state() 52 .ancestors() 53 .nth(3) 54 .expect("state root"); 55 fs::create_dir_all(root).expect("state root"); 56 fs::set_permissions(root, fs::Permissions::from_mode(0o700)).expect("state root mode"); 57 } 58 59 fn metadata(runtime: &rhi::RhiRuntimeContext) -> RhiStateMetadata { 60 let configuration = parse_rhi_config_v1(EXAMPLE.as_bytes(), RhiConfigProfile::RepoLocal) 61 .expect("configuration"); 62 RhiStateMetadata::new( 63 runtime, 64 &configuration, 65 SourceGeneration::new([0x5a; 32]).expect("generation"), 66 1_725_000_000_000, 67 ) 68 .expect("metadata") 69 } 70 71 fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { 72 let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time"); 73 let build = MigrationBuildIdentity::new( 74 env!("CARGO_PKG_VERSION"), 75 "1111111111111111111111111111111111111111", 76 "053d0c750bf9cd683c6ea37cefe7e79617ba629f", 77 "rustc-test", 78 "test-target", 79 "service-host", 80 1, 81 rhi::RHI_STATE_SCHEMA_VERSION, 82 1, 83 1, 84 1, 85 ) 86 .expect("build identity"); 87 (applied_at, build) 88 } 89 90 async fn offline_connection(runtime: &rhi::RhiRuntimeContext) -> SqliteConnection { 91 let options = SqliteConnectOptions::new() 92 .filename(runtime.artifacts().state_database()) 93 .create_if_missing(false) 94 .foreign_keys(false); 95 SqliteConnection::connect_with(&options) 96 .await 97 .expect("offline fixture connection") 98 } 99 100 async fn downgrade_fixture_to_schema_v7(runtime: &rhi::RhiRuntimeContext) { 101 let mut connection = offline_connection(runtime).await; 102 let metadata_guard: String = sqlx::query_scalar( 103 "SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND name = 'radroots_service_metadata_guard_update'", 104 ) 105 .fetch_one(&mut connection) 106 .await 107 .expect("metadata guard SQL"); 108 let migration_no_update: String = sqlx::query_scalar( 109 "SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND name = 'schema_migrations_no_update'", 110 ) 111 .fetch_one(&mut connection) 112 .await 113 .expect("migration update guard SQL"); 114 let migration_no_delete: String = sqlx::query_scalar( 115 "SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND name = 'schema_migrations_no_delete'", 116 ) 117 .fetch_one(&mut connection) 118 .await 119 .expect("migration delete guard SQL"); 120 for statement in [ 121 "DROP TRIGGER rhi_admin_operations_guard_update", 122 "DROP TABLE rhi_admin_operations", 123 "DROP TABLE presence_attempts", 124 "DROP TABLE presence_targets", 125 "DROP TABLE presence_outbox", 126 "DROP TRIGGER presence_desired_state_guard_insert", 127 "DROP TRIGGER presence_desired_state_guard_update", 128 "DROP TRIGGER presence_desired_state_no_delete", 129 "DROP TABLE presence_desired_state", 130 "DROP TRIGGER reconciliation_jobs_shape_guard_insert", 131 "DROP TRIGGER reconciliation_jobs_shape_guard_update", 132 "DROP TRIGGER radroots_service_metadata_guard_update", 133 "DROP TRIGGER schema_migrations_no_update", 134 "DROP TRIGGER schema_migrations_no_delete", 135 "UPDATE radroots_service_metadata SET state_schema_version = 7 WHERE singleton = 1", 136 "DELETE FROM schema_migrations WHERE version IN (8, 9, 10, 11)", 137 ] { 138 sqlx::query(statement) 139 .execute(&mut connection) 140 .await 141 .expect("downgrade exact v8 fixture state"); 142 } 143 for statement in [metadata_guard, migration_no_update, migration_no_delete] { 144 sqlx::query(sqlx::AssertSqlSafe(statement.as_str())) 145 .execute(&mut connection) 146 .await 147 .expect("restore shared immutable guard"); 148 } 149 connection.close().await.expect("downgrade fixture close"); 150 } 151 152 async fn insert_historical_reconciliation_job( 153 runtime: &rhi::RhiRuntimeContext, 154 state: &str, 155 next_attempt_unix_ms: Option<i64>, 156 lease_owner: Option<Vec<u8>>, 157 lease_expires_unix_ms: Option<i64>, 158 ) { 159 let mut connection = offline_connection(runtime).await; 160 let trade_id = [0x51_u8; 16]; 161 sqlx::query( 162 "INSERT INTO trade_dirty_generations (trade_id, generation, evidence_policy_sha256, updated_at_unix_s) VALUES (?, 1, ?, 1)", 163 ) 164 .bind(trade_id.as_slice()) 165 .bind([0x52_u8; 32].as_slice()) 166 .execute(&mut connection) 167 .await 168 .expect("historical dirty generation"); 169 sqlx::query( 170 r#"INSERT INTO reconciliation_jobs ( 171 job_id, trade_id, input_generation, evidence_policy_sha256, 172 state, revision, attempt_count, failure_count, max_attempts, 173 lease_duration_ms, lease_renewal_ms, initial_backoff_ms, 174 maximum_backoff_ms, next_attempt_unix_ms, lease_owner, 175 lease_expires_unix_ms, created_at_unix_ms, updated_at_unix_ms 176 ) VALUES (?, ?, 1, ?, ?, 1, ?, 0, 3, 30000, 10000, 100, 177 1000, ?, ?, ?, 1, 1)"#, 178 ) 179 .bind([0x53_u8; 32].as_slice()) 180 .bind(trade_id.as_slice()) 181 .bind([0x52_u8; 32].as_slice()) 182 .bind(state) 183 .bind(i64::from(state == "leased")) 184 .bind(next_attempt_unix_ms) 185 .bind(lease_owner) 186 .bind(lease_expires_unix_ms) 187 .execute(&mut connection) 188 .await 189 .expect("historical nullable reconciliation row admitted by schema v7"); 190 connection.close().await.expect("historical fixture close"); 191 } 192 193 #[tokio::test] 194 async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly() { 195 let directory = tempfile::tempdir().expect("temporary root"); 196 let runtime = runtime(directory.path(), "primary"); 197 prepare_state_root(&runtime); 198 let metadata = metadata(&runtime); 199 let state = runtime.artifacts().state_database(); 200 let lock = runtime.artifacts().state_lock(); 201 202 assert!(!runtime.context().paths().state().exists()); 203 assert!(!state.exists()); 204 let (applied_at, build) = migration_evidence(); 205 initialize_rhi_state(&runtime, &metadata, applied_at, &build) 206 .await 207 .expect("create-new initialization"); 208 assert!(state.is_file()); 209 assert!(lock.is_file()); 210 assert_eq!( 211 fs::metadata(state).unwrap().permissions().mode() & 0o777, 212 0o600 213 ); 214 assert_eq!( 215 fs::metadata(lock).unwrap().permissions().mode() & 0o777, 216 0o600 217 ); 218 219 let duplicate = initialize_rhi_state(&runtime, &metadata, applied_at, &build) 220 .await 221 .expect_err("second initialization must fail"); 222 assert_eq!(duplicate.kind(), RhiStateHostErrorKind::Initialize); 223 224 let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 225 .await 226 .expect("existing writable state"); 227 assert_eq!(writer.mode(), RhiStateHostMode::ReadWriteExisting); 228 assert_eq!( 229 format!("{writer:?}"), 230 "RhiStateHost { mode: ReadWriteExisting, state: \"[sealed]\" }" 231 ); 232 233 let contended = open_rhi_state_inspection(&runtime, &metadata) 234 .await 235 .expect_err("inspection must not bypass active writer authority"); 236 assert_eq!(contended.kind(), RhiStateHostErrorKind::InspectionOpen); 237 writer.close().await.expect("writer close"); 238 writer.close().await.expect("idempotent writer close"); 239 240 let inspection = open_rhi_state_inspection(&runtime, &metadata) 241 .await 242 .expect("existing inspection state"); 243 assert_eq!(inspection.mode(), RhiStateHostMode::ReadOnlyInspection); 244 let repositories = inspection.repositories(); 245 assert_eq!( 246 format!("{repositories:?}"), 247 "RhiStateRepositories { mode: ReadOnlyInspection, state: \"[sealed]\" }" 248 ); 249 assert_eq!( 250 repositories.sources().kind(), 251 RhiStateRepositoryKind::Source 252 ); 253 assert_eq!( 254 repositories.source_cursors().kind(), 255 RhiStateRepositoryKind::SourceCursor 256 ); 257 assert_eq!( 258 repositories.source_completions().kind(), 259 RhiStateRepositoryKind::SourceCompletion 260 ); 261 assert_eq!( 262 repositories.signed_events().kind(), 263 RhiStateRepositoryKind::SignedEvent 264 ); 265 assert_eq!( 266 repositories.mutations().kind(), 267 RhiStateRepositoryKind::Mutation 268 ); 269 assert_eq!( 270 repositories.provenance().kind(), 271 RhiStateRepositoryKind::Provenance 272 ); 273 assert_eq!( 274 repositories.dirty_trades().kind(), 275 RhiStateRepositoryKind::DirtyTrade 276 ); 277 assert_eq!( 278 repositories.reconciliation_jobs().kind(), 279 RhiStateRepositoryKind::ReconciliationJob 280 ); 281 assert_eq!( 282 repositories.reconciliation_attempts().kind(), 283 RhiStateRepositoryKind::ReconciliationAttempt 284 ); 285 assert_eq!( 286 repositories.evidence_manifests().kind(), 287 RhiStateRepositoryKind::EvidenceManifest 288 ); 289 assert_eq!( 290 repositories.projections().kind(), 291 RhiStateRepositoryKind::Projection 292 ); 293 assert_eq!( 294 repositories.reports().kind(), 295 RhiStateRepositoryKind::Report 296 ); 297 assert_eq!( 298 repositories.supersessions().kind(), 299 RhiStateRepositoryKind::Supersession 300 ); 301 assert_eq!( 302 repositories.signed_attestation_events().kind(), 303 RhiStateRepositoryKind::SignedAttestationEvent 304 ); 305 assert_eq!( 306 repositories.publication_outbox().kind(), 307 RhiStateRepositoryKind::PublicationOutbox 308 ); 309 assert_eq!( 310 repositories.publication_targets().kind(), 311 RhiStateRepositoryKind::PublicationTarget 312 ); 313 assert_eq!( 314 repositories.publication_attempts().kind(), 315 RhiStateRepositoryKind::PublicationAttempt 316 ); 317 assert_eq!( 318 repositories.desired_presence().kind(), 319 RhiStateRepositoryKind::DesiredPresence 320 ); 321 inspection.close().await.expect("inspection close"); 322 323 let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 324 .await 325 .expect("authority reacquisition after explicit close"); 326 writer.close().await.expect("reopened writer close"); 327 } 328 329 #[tokio::test] 330 async fn publication_schema_rejects_null_state_holes_and_accepted_target_mutation() { 331 let directory = tempfile::tempdir().expect("temporary root"); 332 let runtime = runtime(directory.path(), "publication-schema"); 333 prepare_state_directory(&runtime); 334 let metadata = metadata(&runtime); 335 let (applied_at, build) = migration_evidence(); 336 initialize_rhi_state(&runtime, &metadata, applied_at, &build) 337 .await 338 .expect("state initialization"); 339 340 let options = SqliteConnectOptions::new() 341 .filename(runtime.artifacts().state_database()) 342 .create_if_missing(false) 343 .foreign_keys(false); 344 let mut connection = SqliteConnection::connect_with(&options) 345 .await 346 .expect("offline fixture connection"); 347 348 let outbox_id = [0x21_u8; 32]; 349 let event_id = [0x22_u8; 32]; 350 let event_sha256 = [0x23_u8; 32]; 351 let authority_sha256 = [0x24_u8; 32]; 352 let target_set_sha256 = [0x25_u8; 32]; 353 let missing_pending_schedule = sqlx::query( 354 r#"INSERT INTO publication_outbox ( 355 outbox_id, event_id, event_sha256, publication_authority_sha256, 356 target_set_sha256, target_count, required_target_count, 357 max_attempts, initial_backoff_ms, maximum_backoff_ms, 358 attempt_deadline_ms, state, revision, next_attempt_unix_ms, 359 lease_owner, lease_expires_unix_ms, created_at_unix_ms, 360 updated_at_unix_ms 361 ) VALUES (?, ?, ?, ?, ?, 1, 1, 3, 100, 1000, 5000, 362 'pending', 1, NULL, NULL, NULL, 10, 10)"#, 363 ) 364 .bind(outbox_id.as_slice()) 365 .bind(event_id.as_slice()) 366 .bind(event_sha256.as_slice()) 367 .bind(authority_sha256.as_slice()) 368 .bind(target_set_sha256.as_slice()) 369 .execute(&mut connection) 370 .await; 371 assert!( 372 missing_pending_schedule.is_err(), 373 "pending outbox rows require a concrete next-attempt time" 374 ); 375 376 sqlx::query( 377 r#"INSERT INTO publication_outbox ( 378 outbox_id, event_id, event_sha256, publication_authority_sha256, 379 target_set_sha256, target_count, required_target_count, 380 max_attempts, initial_backoff_ms, maximum_backoff_ms, 381 attempt_deadline_ms, state, revision, next_attempt_unix_ms, 382 lease_owner, lease_expires_unix_ms, created_at_unix_ms, 383 updated_at_unix_ms 384 ) VALUES (?, ?, ?, ?, ?, 1, 1, 3, 100, 1000, 5000, 385 'pending', 1, 10, NULL, NULL, 10, 10)"#, 386 ) 387 .bind(outbox_id.as_slice()) 388 .bind(event_id.as_slice()) 389 .bind(event_sha256.as_slice()) 390 .bind(authority_sha256.as_slice()) 391 .bind(target_set_sha256.as_slice()) 392 .execute(&mut connection) 393 .await 394 .expect("valid pending outbox row"); 395 396 sqlx::query( 397 r#"INSERT INTO publication_targets ( 398 outbox_id, target_ordinal, relay_id, required, state, revision, 399 attempt_count, next_attempt_unix_ms, last_attempt_id, 400 updated_at_unix_ms 401 ) VALUES (?, 0, 'relay_a', 1, 'accepted', 1, 0, NULL, NULL, 10)"#, 402 ) 403 .bind(outbox_id.as_slice()) 404 .execute(&mut connection) 405 .await 406 .expect("accepted target fixture"); 407 let accepted_mutation = sqlx::query( 408 r#"UPDATE publication_targets 409 SET revision = 2, updated_at_unix_ms = 11 410 WHERE outbox_id = ? AND target_ordinal = 0"#, 411 ) 412 .bind(outbox_id.as_slice()) 413 .execute(&mut connection) 414 .await; 415 assert!( 416 accepted_mutation.is_err(), 417 "accepted publication targets are terminal" 418 ); 419 420 connection.close().await.expect("fixture connection close"); 421 } 422 423 #[tokio::test] 424 async fn schema_v8_scans_historical_nullable_job_state_and_installs_permanent_guards() { 425 for (instance, state, next_attempt, owner, expiry) in [ 426 ("missing-ready-time", "ready", None, None, None), 427 ("missing-lease-owner", "leased", None, None, Some(30_001)), 428 ( 429 "missing-lease-expiry", 430 "leased", 431 None, 432 Some(vec![0x61; 16]), 433 None, 434 ), 435 ] { 436 let directory = tempfile::tempdir().expect("temporary root"); 437 let runtime = runtime(directory.path(), instance); 438 prepare_state_directory(&runtime); 439 let metadata = metadata(&runtime); 440 let (applied_at, build) = migration_evidence(); 441 initialize_rhi_state(&runtime, &metadata, applied_at, &build) 442 .await 443 .expect("current-schema initialization"); 444 downgrade_fixture_to_schema_v7(&runtime).await; 445 insert_historical_reconciliation_job(&runtime, state, next_attempt, owner, expiry).await; 446 447 let error = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 448 .await 449 .expect_err("invalid historical row must block migration"); 450 assert_eq!(error.kind(), RhiStateHostErrorKind::ReadWriteOpen); 451 let mut connection = offline_connection(&runtime).await; 452 let durable: (i64, i64, i64, i64) = sqlx::query_as( 453 r#"SELECT 454 (SELECT state_schema_version FROM radroots_service_metadata WHERE singleton = 1), 455 (SELECT COUNT(*) FROM schema_migrations WHERE version = 8), 456 (SELECT COUNT(*) FROM sqlite_schema WHERE type = 'trigger' 457 AND name IN ('reconciliation_jobs_shape_guard_insert', 458 'reconciliation_jobs_shape_guard_update')), 459 (SELECT COUNT(*) FROM reconciliation_jobs)"#, 460 ) 461 .fetch_one(&mut connection) 462 .await 463 .expect("failed migration state"); 464 assert_eq!(durable, (7, 0, 0, 1)); 465 connection.close().await.expect("failed fixture close"); 466 } 467 468 let directory = tempfile::tempdir().expect("temporary root"); 469 let runtime = runtime(directory.path(), "valid-schema-v7"); 470 prepare_state_directory(&runtime); 471 let metadata = metadata(&runtime); 472 let (applied_at, build) = migration_evidence(); 473 initialize_rhi_state(&runtime, &metadata, applied_at, &build) 474 .await 475 .expect("current-schema initialization"); 476 downgrade_fixture_to_schema_v7(&runtime).await; 477 insert_historical_reconciliation_job( 478 &runtime, 479 "leased", 480 None, 481 Some(vec![0x62; 16]), 482 Some(30_001), 483 ) 484 .await; 485 let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) 486 .await 487 .expect("valid schema-v7 prefix migrates"); 488 writer.close().await.expect("migrated writer close"); 489 490 let mut connection = offline_connection(&runtime).await; 491 let migrated: (i64, i64, i64, i64) = sqlx::query_as( 492 r#"SELECT 493 (SELECT state_schema_version FROM radroots_service_metadata WHERE singleton = 1), 494 (SELECT COUNT(*) FROM schema_migrations WHERE version = 8), 495 (SELECT COUNT(*) FROM sqlite_schema WHERE type = 'trigger' 496 AND name IN ('reconciliation_jobs_shape_guard_insert', 497 'reconciliation_jobs_shape_guard_update')), 498 (SELECT COUNT(*) FROM sqlite_schema 499 WHERE name = 'reconciliation_jobs_shape_scan_v1')"#, 500 ) 501 .fetch_one(&mut connection) 502 .await 503 .expect("migrated schema state"); 504 assert_eq!(migrated, (11, 1, 2, 0)); 505 506 let invalid_insert = sqlx::query( 507 r#"INSERT INTO reconciliation_jobs ( 508 job_id, trade_id, input_generation, evidence_policy_sha256, 509 state, revision, attempt_count, failure_count, max_attempts, 510 lease_duration_ms, lease_renewal_ms, initial_backoff_ms, 511 maximum_backoff_ms, next_attempt_unix_ms, lease_owner, 512 lease_expires_unix_ms, created_at_unix_ms, updated_at_unix_ms 513 ) VALUES (?, ?, 1, ?, 'ready', 1, 0, 0, 3, 30000, 10000, 514 100, 1000, NULL, NULL, NULL, 1, 1)"#, 515 ) 516 .bind([0x63_u8; 32].as_slice()) 517 .bind([0x64_u8; 16].as_slice()) 518 .bind([0x65_u8; 32].as_slice()) 519 .execute(&mut connection) 520 .await; 521 assert!( 522 invalid_insert.is_err(), 523 "insert guard rejects a ready NULL hole" 524 ); 525 526 let invalid_update = sqlx::query( 527 r#"UPDATE reconciliation_jobs 528 SET revision = revision + 1, updated_at_unix_ms = updated_at_unix_ms + 1, 529 lease_owner = NULL 530 WHERE job_id = ?"#, 531 ) 532 .bind([0x53_u8; 32].as_slice()) 533 .execute(&mut connection) 534 .await; 535 assert!( 536 invalid_update.is_err(), 537 "update guard rejects a leased NULL hole" 538 ); 539 connection.close().await.expect("guard fixture close"); 540 } 541 542 #[tokio::test] 543 async fn missing_state_and_mismatched_evidence_fail_before_database_creation() { 544 let invalid_directory = tempfile::tempdir().expect("invalid temporary root"); 545 let invalid_runtime = runtime(invalid_directory.path(), "invalid"); 546 let invalid_metadata = metadata(&invalid_runtime); 547 let (applied_at, build) = migration_evidence(); 548 let invalid_build = MigrationBuildIdentity::new( 549 env!("CARGO_PKG_VERSION"), 550 "1111111111111111111111111111111111111111", 551 "053d0c750bf9cd683c6ea37cefe7e79617ba629f", 552 "rustc-test", 553 "test-target", 554 "service-host", 555 2, 556 1, 557 1, 558 1, 559 1, 560 ) 561 .expect("structurally valid mismatched build"); 562 let invalid = initialize_rhi_state( 563 &invalid_runtime, 564 &invalid_metadata, 565 applied_at, 566 &invalid_build, 567 ) 568 .await 569 .expect_err("migration build must match RHI policy before I/O"); 570 assert_eq!(invalid.kind(), RhiStateHostErrorKind::InvalidEvidence); 571 assert!(!invalid_runtime.context().paths().state().exists()); 572 573 let missing_directory = tempfile::tempdir().expect("missing temporary root"); 574 let missing_runtime = runtime(missing_directory.path(), "missing"); 575 prepare_state_root(&missing_runtime); 576 let missing_metadata = metadata(&missing_runtime); 577 let missing = 578 open_rhi_state_read_write(&missing_runtime, &missing_metadata, applied_at, &build) 579 .await 580 .expect_err("existing-only open never provisions the service suffix"); 581 assert_eq!(missing.kind(), RhiStateHostErrorKind::ReadWriteOpen); 582 assert!(!missing_runtime.context().paths().state().exists()); 583 584 let directory = tempfile::tempdir().expect("temporary root"); 585 let primary = runtime(directory.path(), "primary"); 586 let secondary = runtime(directory.path(), "secondary"); 587 prepare_state_directory(&primary); 588 let primary_metadata = metadata(&primary); 589 let missing = open_rhi_state_read_write(&primary, &primary_metadata, applied_at, &build) 590 .await 591 .expect_err("missing state is never created by open"); 592 assert_eq!(missing.kind(), RhiStateHostErrorKind::ReadWriteOpen); 593 assert!(!primary.artifacts().state_database().exists()); 594 595 let mismatch = initialize_rhi_state(&secondary, &primary_metadata, applied_at, &build) 596 .await 597 .expect_err("cross-instance metadata"); 598 assert_eq!(mismatch.kind(), RhiStateHostErrorKind::InvalidEvidence); 599 assert_eq!(mismatch.code(), "state_evidence_invalid"); 600 assert!(Error::source(&mismatch).is_none()); 601 let rendered = format!("{mismatch} {mismatch:?}"); 602 assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); 603 assert!(!rendered.contains("state.sqlite")); 604 assert!(!secondary.artifacts().state_database().exists()); 605 } 606 607 #[test] 608 fn public_lifecycle_source_is_sealed() { 609 assert!(LIB_SOURCE.contains("mod state_host;")); 610 assert!(!LIB_SOURCE.contains("pub mod state_host;")); 611 assert!(HOST_SOURCE.contains("host: ServiceSqliteHost")); 612 assert!(!HOST_SOURCE.contains("pub host:")); 613 for required in [ 614 "ServiceSqliteInitializer", 615 "ServiceSqliteInitializerFuture", 616 ".state_directory_plan()", 617 ".and_then(|plan| plan.provision())", 618 ] { 619 assert!( 620 HOST_SOURCE.contains(required), 621 "missing sealed initialization boundary `{required}`" 622 ); 623 } 624 for forbidden in [ 625 "pub fn transaction", 626 "pub async fn transaction", 627 "pub fn pool", 628 "pub fn connection", 629 "pub fn into_inner", 630 "pub fn executor", 631 "MigrationDescriptor::", 632 "raw_sql", 633 "CREATE TABLE", 634 "PRAGMA application_id", 635 "PathBuf", 636 "use sqlx::", 637 "SqliteConnectOptions", 638 "ConnectOptions", 639 "create_dir_all", 640 "try_exists", 641 ] { 642 assert!( 643 !HOST_SOURCE.contains(forbidden), 644 "found forbidden lifecycle authority `{forbidden}`" 645 ); 646 } 647 }