rhi

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

commit cc7fb52923af03e48d875af908723da2e3339e50
parent 07480ee68e9f3ee9357527d4c382f1c36e3520bd
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 20:39:59 +0000

state: seal RHI SQLite host lifecycle

Diffstat:
MCargo.lock | 3+++
MCargo.toml | 3+++
MREADME | 8++++++++
Mradroots.service.source-lock.v2.toml | 2+-
Msrc/lib.rs | 5+++++
Asrc/state_host.rs | 313+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 7+++++++
Atests/services_hardening_state_host.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 527 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1866,13 +1866,16 @@ dependencies = [ "radroots_secrets", "radroots_service_host", "radroots_service_sqlite", + "radroots_storage", "radroots_trade", "rand 0.9.2", "serde", "serde_json", "sha2", + "sqlx", "tempfile", "thiserror 2.0.18", + "tokio", "toml", "url", "zeroize", diff --git a/Cargo.toml b/Cargo.toml @@ -63,6 +63,7 @@ rand = { version = "0.9" } serde = { version = "1", default-features = false } serde_json = { version = "1", default-features = false } sha2 = { version = "0.10" } +sqlx = { version = "0.9.0", default-features = false, features = ["sqlite-bundled"] } thiserror = { version = "2" } tempfile = { version = "3" } toml = { version = "0.8" } @@ -70,3 +71,5 @@ url = "2" zeroize = { version = "1" } [dev-dependencies] +radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", default-features = false } +tokio = { version = "1", default-features = false, features = ["macros", "rt-multi-thread"] } diff --git a/README b/README @@ -96,6 +96,14 @@ initialization and existing-only host lifecycle remain separate boundaries. Service-owned evidence and attestation tables and their ordered migrations are introduced only by their owning later checkpoints. +The sealed RHI state-host lifecycle now reserves and initializes a missing +canonical database only through an explicit create-new operation. Ordinary +writable and immutable inspection modes open existing state only, validate the +exact schema and migration identities, and retain mutually exclusive shared +authority until explicit idempotent close. Missing state is never initialized +by an open operation. No raw host, pool, connection, transaction, executor, or +path escapes the RHI wrapper. + Validate the standalone crate through extbuild: ```text diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml @@ -7,7 +7,7 @@ architecture = "radroots.crates.release.v2" workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" version = "0.1.0-alpha" source_archive_sha256 = "b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0" -cargo_lock_sha256 = "407af5f68ddb487db8867bb84e20c8a4645351c96c3695ae41973c4ea4b44a12" +cargo_lock_sha256 = "26cb57d0658c9dbb1824f8bcc97d8a43451d1937d5ced8f3af418ce987ef3ec8" rust_version = "1.97.1" host_feature_profile = "service-host" diff --git a/src/lib.rs b/src/lib.rs @@ -8,6 +8,7 @@ pub mod host_identity; pub mod identity_storage; mod runtime_context; mod state_catalog; +mod state_host; pub use cli_v1::{ RhiBootstrapProfileV1, RhiCliInvocationV1, RhiCliOutputModeV1, RhiCliV1Error, @@ -36,3 +37,7 @@ pub use state_catalog::{ RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, validate_rhi_state_catalogs, }; +pub use state_host::{ + RhiStateHost, RhiStateHostError, RhiStateHostErrorKind, RhiStateHostMode, initialize_rhi_state, + open_rhi_state_inspection, open_rhi_state_read_write, +}; diff --git a/src/state_host.rs b/src/state_host.rs @@ -0,0 +1,313 @@ +//! Sealed lifecycle boundary for the canonical RHI SQLite state catalog. + +use core::fmt; +use std::{error::Error, path::PathBuf}; + +use radroots_service_sqlite::{ + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceDatabaseIdentity, + ServiceDatabaseMetadata, ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, + initialize_database, +}; +use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions}; + +use crate::{ + RHI_STATE_SCHEMA_VERSION, RhiRuntimeContext, rhi_migration_catalog, rhi_schema_catalog, + validate_rhi_state_catalogs, +}; + +/// Stable lifecycle mode of one opened RHI state host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiStateHostMode { + ReadWriteExisting, + ReadOnlyInspection, +} + +/// Stable source-free class for an RHI state-host lifecycle failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiStateHostErrorKind { + InvalidPaths, + InvalidEvidence, + Catalog, + Initialize, + ReadWriteOpen, + InspectionOpen, + Close, +} + +impl RhiStateHostErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidPaths => "state_paths_invalid", + Self::InvalidEvidence => "state_evidence_invalid", + Self::Catalog => "state_catalog_invalid", + Self::Initialize => "state_initialize_failed", + Self::ReadWriteOpen => "state_read_write_open_failed", + Self::InspectionOpen => "state_inspection_open_failed", + Self::Close => "state_close_failed", + } + } +} + +/// Redacted RHI state-host lifecycle failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiStateHostError { + kind: RhiStateHostErrorKind, +} + +impl RhiStateHostError { + const fn new(kind: RhiStateHostErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiStateHostErrorKind { + self.kind + } + + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for RhiStateHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiStateHostErrorKind::InvalidPaths => "RHI state paths are invalid", + RhiStateHostErrorKind::InvalidEvidence => "RHI state identity evidence is invalid", + RhiStateHostErrorKind::Catalog => "RHI state catalogs are invalid", + RhiStateHostErrorKind::Initialize => "RHI state initialization failed", + RhiStateHostErrorKind::ReadWriteOpen => "RHI writable state could not be opened", + RhiStateHostErrorKind::InspectionOpen => "RHI inspection state could not be opened", + RhiStateHostErrorKind::Close => "RHI state host could not be closed", + }) + } +} + +impl fmt::Debug for RhiStateHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiStateHostError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiStateHostError {} + +/// One opened RHI state catalog whose raw SQLite authority remains sealed. +/// +/// Callers cannot construct the wrapper or extract the shared host: +/// +/// ```compile_fail +/// use rhi::{RhiStateHost, RhiStateHostMode}; +/// +/// let _ = RhiStateHost { +/// host: todo!(), +/// mode: RhiStateHostMode::ReadWriteExisting, +/// }; +/// ``` +/// +/// The wrapper intentionally exposes no transaction or connection escape: +/// +/// ```compile_fail +/// use rhi::RhiStateHost; +/// +/// fn bypass(host: &RhiStateHost) { +/// let _ = host.transaction(|_| async { Ok::<_, ()>(()) }); +/// } +/// ``` +pub struct RhiStateHost { + host: ServiceSqliteHost, + mode: RhiStateHostMode, +} + +impl RhiStateHost { + /// Returns the lifecycle mode selected when this host was opened. + #[must_use] + pub const fn mode(&self) -> RhiStateHostMode { + self.mode + } + + /// Drains the shared host and explicitly releases retained authority. + pub async fn close(&self) -> Result<(), RhiStateHostError> { + self.host + .close() + .await + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Close)) + } +} + +impl fmt::Debug for RhiStateHost { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiStateHost") + .field("mode", &self.mode) + .field("state", &"[sealed]") + .finish() + } +} + +/// Creates a missing RHI catalog exactly once and releases initialization authority. +/// +/// This function never opens an existing database as initialization. The caller +/// injects the shared metadata evidence; Step 171 owns its exact RHI application, +/// configuration, evidence-policy, identity, and contract-version bindings. +pub async fn initialize_rhi_state( + runtime: &RhiRuntimeContext, + metadata: &ServiceDatabaseMetadata, +) -> Result<(), RhiStateHostError> { + let paths = state_paths(runtime)?; + require_metadata(runtime, metadata)?; + let (migrations, schema) = catalogs()?; + let mut authority = initialize_database( + &paths, + OpenMode::Initialize, + metadata, + &schema, + initialize_empty_catalog, + ) + .await + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize))?; + authority + .release() + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Initialize))?; + drop(migrations); + Ok(()) +} + +/// Opens an already initialized RHI catalog with exclusive writer authority. +/// +/// Missing state is never created. Migration time and build identity remain +/// explicit injected evidence even while the baseline migration catalog is +/// empty. +pub async fn open_rhi_state_read_write( + runtime: &RhiRuntimeContext, + identity: &ServiceDatabaseIdentity, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, +) -> Result<RhiStateHost, RhiStateHostError> { + let paths = state_paths(runtime)?; + require_identity(runtime, identity)?; + let (migrations, schema) = catalogs()?; + let (host, outcome) = ServiceSqliteHost::open_read_write_existing( + &paths, + identity, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + applied_at, + build, + &[], + ) + .await + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::ReadWriteOpen))?; + if outcome.initial_version() != RHI_STATE_SCHEMA_VERSION + || outcome.final_version() != RHI_STATE_SCHEMA_VERSION + || outcome.applied_count() != 0 + { + let _ = host.close().await; + return Err(RhiStateHostError::new(RhiStateHostErrorKind::Catalog)); + } + Ok(RhiStateHost { + host, + mode: RhiStateHostMode::ReadWriteExisting, + }) +} + +/// Opens an already initialized RHI catalog for immutable inspection. +pub async fn open_rhi_state_inspection( + runtime: &RhiRuntimeContext, + identity: &ServiceDatabaseIdentity, +) -> Result<RhiStateHost, RhiStateHostError> { + let paths = state_paths(runtime)?; + require_identity(runtime, identity)?; + let (migrations, schema) = catalogs()?; + let host = ServiceSqliteHost::open_read_only_inspection( + &paths, + identity, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + ) + .await + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InspectionOpen))?; + Ok(RhiStateHost { + host, + mode: RhiStateHostMode::ReadOnlyInspection, + }) +} + +fn state_paths(runtime: &RhiRuntimeContext) -> Result<ServiceSqlitePaths, RhiStateHostError> { + ServiceSqlitePaths::from_runtime_context(runtime.context()) + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InvalidPaths)) +} + +fn require_metadata( + runtime: &RhiRuntimeContext, + metadata: &ServiceDatabaseMetadata, +) -> Result<(), RhiStateHostError> { + let matches = metadata.service() == runtime.context().service() + && metadata.instance() == runtime.context().instance() + && metadata.state_schema_version().get() == RHI_STATE_SCHEMA_VERSION; + matches + .then_some(()) + .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence)) +} + +fn require_identity( + runtime: &RhiRuntimeContext, + identity: &ServiceDatabaseIdentity, +) -> Result<(), RhiStateHostError> { + let matches = identity.service() == runtime.context().service() + && identity.instance() == runtime.context().instance() + && identity.supported_state_schema_version().get() == RHI_STATE_SCHEMA_VERSION; + matches + .then_some(()) + .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence)) +} + +fn catalogs() -> Result< + ( + radroots_service_sqlite::MigrationCatalog, + radroots_service_sqlite::SchemaCatalog, + ), + RhiStateHostError, +> { + let migrations = rhi_migration_catalog() + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; + let schema = + rhi_schema_catalog().map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; + validate_rhi_state_catalogs(&migrations, &schema) + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::Catalog))?; + Ok((migrations, schema)) +} + +#[derive(Debug)] +struct EmptyCatalogInitializationError; + +impl fmt::Display for EmptyCatalogInitializationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RHI baseline database reservation could not be opened") + } +} + +impl Error for EmptyCatalogInitializationError {} + +async fn initialize_empty_catalog(path: PathBuf) -> Result<(), EmptyCatalogInitializationError> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .disable_statement_logging(); + let connection = SqliteConnection::connect_with(&options) + .await + .map_err(|_| EmptyCatalogInitializationError)?; + connection + .close() + .await + .map_err(|_| EmptyCatalogInitializationError) +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -62,6 +62,13 @@ fn shared_service_sqlite_is_the_only_catalog_authority() { } #[test] +fn shared_storage_test_evidence_is_exactly_source_locked() { + assert!(MANIFEST.contains( + "radroots_storage = { git = \"https://github.com/radrootslabs/lib\", rev = \"7d7b454b4c9ed86569671993bd03ca868b676665\", version = \"=0.1.0-alpha\", default-features = false }" + )); +} + +#[test] fn source_lock_binds_the_current_cargo_lock() { let digest = lower_hex(&Sha256::digest(include_bytes!("../Cargo.lock"))); assert!(SOURCE_LOCK.starts_with( diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs @@ -0,0 +1,187 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{error::Error, fs, num::NonZeroU32, os::unix::fs::PermissionsExt, path::Path}; + +use radroots_service_sqlite::{ + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceDatabaseMetadata, + ServiceSqliteApplicationId, ServiceSqlitePaths, +}; +use radroots_storage::event::SourceGeneration; +use rhi::{ + RHI_STATE_SCHEMA_VERSION, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, + RhiStateHostErrorKind, RhiStateHostMode, initialize_rhi_state, open_rhi_state_inspection, + open_rhi_state_read_write, parse_rhi_cli_v1_from, resolve_rhi_runtime_context, +}; + +const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); + +fn runtime(root: &Path, instance: &str) -> rhi::RhiRuntimeContext { + let root = root.to_str().expect("UTF-8 temporary root"); + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + instance, + "--repo-local-root", + root, + "run", + ]) + .expect("valid test invocation"); + resolve_rhi_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context") +} + +fn prepare_state_directory(runtime: &rhi::RhiRuntimeContext) { + let directory = runtime.context().paths().state(); + fs::create_dir_all(directory).expect("state directory"); + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode"); +} + +fn metadata(runtime: &rhi::RhiRuntimeContext) -> ServiceDatabaseMetadata { + let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("SQLite paths"); + ServiceDatabaseMetadata::new( + &paths, + SourceGeneration::new([0x5a; 32]).expect("generation"), + NonZeroU32::new(RHI_STATE_SCHEMA_VERSION).expect("schema version"), + 1_725_000_000_000, + ServiceSqliteApplicationId::new(0x5248_4931).expect("test application ID"), + ) + .expect("metadata") +} + +fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { + let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time"); + let build = MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "7d7b454b4c9ed86569671993bd03ca868b676665", + "rustc-test", + "test-target", + "service-host", + 1, + 1, + 1, + 1, + 1, + ) + .expect("build identity"); + (applied_at, build) +} + +#[tokio::test] +async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "primary"); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime); + let identity = metadata.identity(); + let state = runtime.artifacts().state_database(); + let lock = runtime.artifacts().state_lock(); + + assert!(!state.exists()); + initialize_rhi_state(&runtime, &metadata) + .await + .expect("create-new initialization"); + assert!(state.is_file()); + assert!(lock.is_file()); + assert_eq!( + fs::metadata(state).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(lock).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + let duplicate = initialize_rhi_state(&runtime, &metadata) + .await + .expect_err("second initialization must fail"); + assert_eq!(duplicate.kind(), RhiStateHostErrorKind::Initialize); + + let (applied_at, build) = migration_evidence(); + let writer = open_rhi_state_read_write(&runtime, &identity, applied_at, &build) + .await + .expect("existing writable state"); + assert_eq!(writer.mode(), RhiStateHostMode::ReadWriteExisting); + assert_eq!( + format!("{writer:?}"), + "RhiStateHost { mode: ReadWriteExisting, state: \"[sealed]\" }" + ); + + let contended = open_rhi_state_inspection(&runtime, &identity) + .await + .expect_err("inspection must not bypass active writer authority"); + assert_eq!(contended.kind(), RhiStateHostErrorKind::InspectionOpen); + writer.close().await.expect("writer close"); + writer.close().await.expect("idempotent writer close"); + + let inspection = open_rhi_state_inspection(&runtime, &identity) + .await + .expect("existing inspection state"); + assert_eq!(inspection.mode(), RhiStateHostMode::ReadOnlyInspection); + inspection.close().await.expect("inspection close"); + + let writer = open_rhi_state_read_write(&runtime, &identity, applied_at, &build) + .await + .expect("authority reacquisition after explicit close"); + writer.close().await.expect("reopened writer close"); +} + +#[tokio::test] +async fn missing_state_and_mismatched_evidence_fail_before_database_creation() { + let directory = tempfile::tempdir().expect("temporary root"); + let primary = runtime(directory.path(), "primary"); + let secondary = runtime(directory.path(), "secondary"); + prepare_state_directory(&primary); + let primary_metadata = metadata(&primary); + let primary_identity = primary_metadata.identity(); + let (applied_at, build) = migration_evidence(); + + let missing = open_rhi_state_read_write(&primary, &primary_identity, applied_at, &build) + .await + .expect_err("missing state is never created by open"); + assert_eq!(missing.kind(), RhiStateHostErrorKind::ReadWriteOpen); + assert!(!primary.artifacts().state_database().exists()); + + let mismatch = initialize_rhi_state(&secondary, &primary_metadata) + .await + .expect_err("cross-instance metadata"); + assert_eq!(mismatch.kind(), RhiStateHostErrorKind::InvalidEvidence); + assert_eq!(mismatch.code(), "state_evidence_invalid"); + assert!(Error::source(&mismatch).is_none()); + let rendered = format!("{mismatch} {mismatch:?}"); + assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); + assert!(!rendered.contains("state.sqlite")); + assert!(!secondary.artifacts().state_database().exists()); +} + +#[test] +fn public_lifecycle_source_is_sealed() { + assert!(LIB_SOURCE.contains("mod state_host;")); + assert!(!LIB_SOURCE.contains("pub mod state_host;")); + assert!(HOST_SOURCE.contains("host: ServiceSqliteHost")); + assert!(!HOST_SOURCE.contains("pub host:")); + for forbidden in [ + "pub fn transaction", + "pub async fn transaction", + "pub fn pool", + "pub fn connection", + "pub fn into_inner", + "pub fn executor", + "MigrationDescriptor::", + "raw_sql", + "CREATE TABLE", + "PRAGMA application_id", + ] { + assert!( + !HOST_SOURCE.contains(forbidden), + "found forbidden lifecycle authority `{forbidden}`" + ); + } +}