rhi

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

commit ff6f54ec8976e3d05bc2ca15d5de6d816747c402
parent cc7fb52923af03e48d875af908723da2e3339e50
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 20:51:55 +0000

rhi: bind state metadata

Diffstat:
MCargo.toml | 2+-
MREADME | 8++++++++
Msrc/config_v1.rs | 4++++
Msrc/lib.rs | 7+++++++
Msrc/state_host.rs | 58+++++++++++++++++++++++++++++-----------------------------
Asrc/state_metadata.rs | 457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 2+-
Mtests/services_hardening_state_host.rs | 40+++++++++++++++++++---------------------
Atests/services_hardening_state_metadata.rs | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 714 insertions(+), 52 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -51,6 +51,7 @@ radroots_protocol = { git = "https://github.com/radrootslabs/lib", rev = "7d7b45 radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } radroots_service_host = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", default-features = false } radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } radroots_trade = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } @@ -71,5 +72,4 @@ 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 @@ -104,6 +104,14 @@ 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. +One sealed RHI state-metadata capability now binds that lifecycle to the exact +service and instance, nonzero source generation, `RDRH` SQLite application ID, +schema version, creation time, fully normalized configuration digest, +contract-defined evidence-policy digest, expected service public identity, and +configuration/state/admin/status/provider contract versions. Its fields are +immutable; ordinary Debug and errors redact paths, identities, generations, +and digests. + Validate the standalone crate through extbuild: ```text diff --git a/src/config_v1.rs b/src/config_v1.rs @@ -213,6 +213,10 @@ impl RhiConfigDocumentV1 { &self.effective } + pub(crate) const fn normalized(&self) -> &Value { + &self.normalized + } + /// Returns the exact number of configured relay bindings. #[must_use] pub fn relay_count(&self) -> usize { diff --git a/src/lib.rs b/src/lib.rs @@ -9,6 +9,7 @@ pub mod identity_storage; mod runtime_context; mod state_catalog; mod state_host; +mod state_metadata; pub use cli_v1::{ RhiBootstrapProfileV1, RhiCliInvocationV1, RhiCliOutputModeV1, RhiCliV1Error, @@ -41,3 +42,9 @@ pub use state_host::{ RhiStateHost, RhiStateHostError, RhiStateHostErrorKind, RhiStateHostMode, initialize_rhi_state, open_rhi_state_inspection, open_rhi_state_read_write, }; +pub use state_metadata::{ + RHI_ADMIN_CONTRACT_VERSION, RHI_PROVIDER_CONTRACT_VERSION, RHI_STATE_APPLICATION_ID, + RHI_STATUS_CONTRACT_VERSION, RhiEvidencePolicyDigest, RhiExpectedPublicIdentity, + RhiNormalizedConfigDigest, RhiStateMetadata, RhiStateMetadataError, RhiStateMetadataErrorKind, + RhiStatePolicyVersions, +}; diff --git a/src/state_host.rs b/src/state_host.rs @@ -4,15 +4,14 @@ use core::fmt; use std::{error::Error, path::PathBuf}; use radroots_service_sqlite::{ - MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceDatabaseIdentity, - ServiceDatabaseMetadata, ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, - initialize_database, + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, + 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, + RHI_STATE_SCHEMA_VERSION, RhiRuntimeContext, RhiStateMetadata, rhi_migration_catalog, + rhi_schema_catalog, validate_rhi_state_catalogs, }; /// Stable lifecycle mode of one opened RHI state host. @@ -124,6 +123,7 @@ impl Error for RhiStateHostError {} pub struct RhiStateHost { host: ServiceSqliteHost, mode: RhiStateHostMode, + metadata: RhiStateMetadata, } impl RhiStateHost { @@ -133,6 +133,12 @@ impl RhiStateHost { self.mode } + /// Returns the immutable RHI metadata bound to this host session. + #[must_use] + pub const fn metadata(&self) -> &RhiStateMetadata { + &self.metadata + } + /// Drains the shared host and explicitly releases retained authority. pub async fn close(&self) -> Result<(), RhiStateHostError> { self.host @@ -159,7 +165,7 @@ impl fmt::Debug for RhiStateHost { /// configuration, evidence-policy, identity, and contract-version bindings. pub async fn initialize_rhi_state( runtime: &RhiRuntimeContext, - metadata: &ServiceDatabaseMetadata, + metadata: &RhiStateMetadata, ) -> Result<(), RhiStateHostError> { let paths = state_paths(runtime)?; require_metadata(runtime, metadata)?; @@ -167,7 +173,7 @@ pub async fn initialize_rhi_state( let mut authority = initialize_database( &paths, OpenMode::Initialize, - metadata, + metadata.database(), &schema, initialize_empty_catalog, ) @@ -187,16 +193,17 @@ pub async fn initialize_rhi_state( /// empty. pub async fn open_rhi_state_read_write( runtime: &RhiRuntimeContext, - identity: &ServiceDatabaseIdentity, + metadata: &RhiStateMetadata, applied_at: MigrationAppliedAtUnixSeconds, build: &MigrationBuildIdentity, ) -> Result<RhiStateHost, RhiStateHostError> { let paths = state_paths(runtime)?; - require_identity(runtime, identity)?; + require_metadata(runtime, metadata)?; + let identity = metadata.database_identity(); let (migrations, schema) = catalogs()?; let (host, outcome) = ServiceSqliteHost::open_read_write_existing( &paths, - identity, + &identity, &migrations, &schema, ServiceSqliteConnectionOptions::reviewed(), @@ -216,20 +223,22 @@ pub async fn open_rhi_state_read_write( Ok(RhiStateHost { host, mode: RhiStateHostMode::ReadWriteExisting, + metadata: metadata.clone(), }) } /// Opens an already initialized RHI catalog for immutable inspection. pub async fn open_rhi_state_inspection( runtime: &RhiRuntimeContext, - identity: &ServiceDatabaseIdentity, + metadata: &RhiStateMetadata, ) -> Result<RhiStateHost, RhiStateHostError> { let paths = state_paths(runtime)?; - require_identity(runtime, identity)?; + require_metadata(runtime, metadata)?; + let identity = metadata.database_identity(); let (migrations, schema) = catalogs()?; let host = ServiceSqliteHost::open_read_only_inspection( &paths, - identity, + &identity, &migrations, &schema, ServiceSqliteConnectionOptions::reviewed(), @@ -239,6 +248,7 @@ pub async fn open_rhi_state_inspection( Ok(RhiStateHost { host, mode: RhiStateHostMode::ReadOnlyInspection, + metadata: metadata.clone(), }) } @@ -249,23 +259,13 @@ fn state_paths(runtime: &RhiRuntimeContext) -> Result<ServiceSqlitePaths, RhiSta 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, + metadata: &RhiStateMetadata, ) -> Result<(), RhiStateHostError> { - let matches = identity.service() == runtime.context().service() - && identity.instance() == runtime.context().instance() - && identity.supported_state_schema_version().get() == RHI_STATE_SCHEMA_VERSION; + let database = metadata.database(); + let matches = metadata.matches_runtime(runtime) + && database.service() == runtime.context().service() + && database.instance() == runtime.context().instance() + && database.state_schema_version().get() == RHI_STATE_SCHEMA_VERSION; matches .then_some(()) .ok_or_else(|| RhiStateHostError::new(RhiStateHostErrorKind::InvalidEvidence)) diff --git a/src/state_metadata.rs b/src/state_metadata.rs @@ -0,0 +1,457 @@ +//! Immutable RHI-specific identity and policy evidence for one state host. + +use core::fmt; +use std::{collections::BTreeMap, error::Error}; + +use nostr::PublicKey; +use radroots_service_sqlite::{ + ServiceDatabaseIdentity, ServiceDatabaseMetadata, ServiceSqliteApplicationId, + ServiceSqlitePaths, +}; +use radroots_storage::event::SourceGeneration; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::{ + RHI_CONFIG_SCHEMA_VERSION, RHI_STATE_SCHEMA_VERSION, RhiBootstrapProfileV1, + RhiConfigDocumentV1, RhiConfigProfile, RhiRuntimeContext, +}; + +const NORMALIZED_CONFIG_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.normalized_config.v1\0"; +const EVIDENCE_POLICY_DIGEST_DOMAIN: &[u8] = b"radroots:rhi-evidence-policy:v1\0"; + +/// SQLite application identity for RHI, encoded as ASCII `RDRH`. +pub const RHI_STATE_APPLICATION_ID: u32 = 0x5244_5248; + +/// Exact version of the governed RHI admin/operator contract. +pub const RHI_ADMIN_CONTRACT_VERSION: u32 = 1; + +/// Exact version of the governed RHI status contract. +pub const RHI_STATUS_CONTRACT_VERSION: u32 = 1; + +/// Exact version of the governed RHI identity-provider contract. +pub const RHI_PROVIDER_CONTRACT_VERSION: u32 = 1; + +/// SHA-256 identity of one fully defaulted normalized RHI configuration. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct RhiNormalizedConfigDigest([u8; 32]); + +impl RhiNormalizedConfigDigest { + /// Returns the exact digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for RhiNormalizedConfigDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiNormalizedConfigDigest([redacted])") + } +} + +/// SHA-256 identity of the normalized configured evidence policy. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct RhiEvidencePolicyDigest([u8; 32]); + +impl RhiEvidencePolicyDigest { + /// Returns the exact digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for RhiEvidencePolicyDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiEvidencePolicyDigest([redacted])") + } +} + +/// One validated canonical expected RHI service public identity. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct RhiExpectedPublicIdentity(Box<str>); + +impl RhiExpectedPublicIdentity { + /// Returns the canonical lowercase 32-byte x-only public key in hex. + #[must_use] + pub fn as_hex(&self) -> &str { + &self.0 + } + + fn from_hex(value: &str) -> Result<Self, RhiStateMetadataError> { + let public_key = PublicKey::from_hex(value) + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Identity))?; + public_key + .xonly() + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Identity))?; + let canonical = public_key.to_hex(); + if canonical != value { + return Err(RhiStateMetadataError::new( + RhiStateMetadataErrorKind::Identity, + )); + } + Ok(Self(canonical.into_boxed_str())) + } +} + +impl fmt::Debug for RhiExpectedPublicIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiExpectedPublicIdentity([redacted])") + } +} + +/// Exact shared contract versions bound to one RHI state-host session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RhiStatePolicyVersions { + configuration: u32, + state: u32, + admin: u32, + status: u32, + provider: u32, +} + +impl RhiStatePolicyVersions { + const fn governed() -> Self { + Self { + configuration: RHI_CONFIG_SCHEMA_VERSION, + state: RHI_STATE_SCHEMA_VERSION, + admin: RHI_ADMIN_CONTRACT_VERSION, + status: RHI_STATUS_CONTRACT_VERSION, + provider: RHI_PROVIDER_CONTRACT_VERSION, + } + } + + /// Returns the exact configuration-contract version. + #[must_use] + pub const fn configuration(self) -> u32 { + self.configuration + } + + /// Returns the exact state-schema version. + #[must_use] + pub const fn state(self) -> u32 { + self.state + } + + /// Returns the exact admin/operator-contract version. + #[must_use] + pub const fn admin(self) -> u32 { + self.admin + } + + /// Returns the exact status-contract version. + #[must_use] + pub const fn status(self) -> u32 { + self.status + } + + /// Returns the exact identity-provider-contract version. + #[must_use] + pub const fn provider(self) -> u32 { + self.provider + } +} + +/// Non-forgeable RHI metadata bound to one runtime context and configuration. +#[derive(Clone, PartialEq, Eq)] +pub struct RhiStateMetadata { + paths: ServiceSqlitePaths, + database: ServiceDatabaseMetadata, + configuration: RhiNormalizedConfigDigest, + evidence_policy: RhiEvidencePolicyDigest, + identity: RhiExpectedPublicIdentity, + policy_versions: RhiStatePolicyVersions, +} + +impl RhiStateMetadata { + /// Derives all state evidence from one sealed runtime context, one admitted + /// normalized configuration, and caller-injected generation/time evidence. + pub fn new( + runtime: &RhiRuntimeContext, + configuration: &RhiConfigDocumentV1, + source_generation: SourceGeneration, + created_at_unix_ms: u64, + ) -> Result<Self, RhiStateMetadataError> { + require_profile_binding(runtime.profile(), configuration.profile())?; + let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()) + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Paths))?; + let application_id = ServiceSqliteApplicationId::new(RHI_STATE_APPLICATION_ID) + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Invariant))?; + let state_schema_version = core::num::NonZeroU32::new(RHI_STATE_SCHEMA_VERSION) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Invariant))?; + let database = ServiceDatabaseMetadata::new( + &paths, + source_generation, + state_schema_version, + created_at_unix_ms, + application_id, + ) + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Database))?; + let normalized = configuration.normalized(); + let configuration_digest = normalized_config_digest(configuration.profile(), normalized)?; + let evidence_policy = evidence_policy_digest(normalized)?; + let identity = expected_identity(normalized)?; + let policy_versions = RhiStatePolicyVersions::governed(); + if [ + policy_versions.configuration, + policy_versions.state, + policy_versions.admin, + policy_versions.status, + policy_versions.provider, + ] + .contains(&0) + { + return Err(RhiStateMetadataError::new( + RhiStateMetadataErrorKind::Invariant, + )); + } + Ok(Self { + paths, + database, + configuration: configuration_digest, + evidence_policy, + identity, + policy_versions, + }) + } + + /// Returns the shared immutable database metadata. + #[must_use] + pub const fn database(&self) -> &ServiceDatabaseMetadata { + &self.database + } + + /// Returns the reopen identity derived from the immutable database metadata. + #[must_use] + pub fn database_identity(&self) -> ServiceDatabaseIdentity { + self.database.identity() + } + + /// Returns the normalized configuration digest. + #[must_use] + pub const fn configuration_digest(&self) -> RhiNormalizedConfigDigest { + self.configuration + } + + /// Returns the normalized evidence-policy digest. + #[must_use] + pub const fn evidence_policy_digest(&self) -> RhiEvidencePolicyDigest { + self.evidence_policy + } + + /// Returns the exact configured service identity binding. + #[must_use] + pub const fn expected_identity(&self) -> &RhiExpectedPublicIdentity { + &self.identity + } + + /// Returns the exact governed policy versions. + #[must_use] + pub const fn policy_versions(&self) -> RhiStatePolicyVersions { + self.policy_versions + } + + pub(crate) fn matches_runtime(&self, runtime: &RhiRuntimeContext) -> bool { + ServiceSqlitePaths::from_runtime_context(runtime.context()) + .is_ok_and(|paths| paths == self.paths) + } +} + +impl fmt::Debug for RhiStateMetadata { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiStateMetadata") + .field("database", &self.database) + .field("configuration", &self.configuration) + .field("evidence_policy", &self.evidence_policy) + .field("identity", &"[redacted]") + .field("policy_versions", &self.policy_versions) + .field("paths", &"[redacted]") + .finish() + } +} + +/// Stable source-free class for invalid RHI state metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiStateMetadataErrorKind { + Profile, + Paths, + Configuration, + EvidencePolicy, + Identity, + Database, + Invariant, +} + +/// Source-free RHI state-metadata construction failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiStateMetadataError { + kind: RhiStateMetadataErrorKind, +} + +impl RhiStateMetadataError { + const fn new(kind: RhiStateMetadataErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> RhiStateMetadataErrorKind { + self.kind + } +} + +impl fmt::Display for RhiStateMetadataError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiStateMetadataErrorKind::Profile => "RHI configuration profile is inconsistent", + RhiStateMetadataErrorKind::Paths => "RHI state metadata paths are invalid", + RhiStateMetadataErrorKind::Configuration => { + "RHI normalized configuration identity is invalid" + } + RhiStateMetadataErrorKind::EvidencePolicy => { + "RHI normalized evidence-policy identity is invalid" + } + RhiStateMetadataErrorKind::Identity => "RHI expected identity binding is invalid", + RhiStateMetadataErrorKind::Database => "RHI database metadata is invalid", + RhiStateMetadataErrorKind::Invariant => "RHI metadata contract is invalid", + }) + } +} + +impl fmt::Debug for RhiStateMetadataError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiStateMetadataError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiStateMetadataError {} + +fn require_profile_binding( + runtime: RhiBootstrapProfileV1, + configuration: RhiConfigProfile, +) -> Result<(), RhiStateMetadataError> { + let matches = match runtime { + RhiBootstrapProfileV1::ServiceHost | RhiBootstrapProfileV1::Interactive => { + configuration == RhiConfigProfile::Production + } + RhiBootstrapProfileV1::RepoLocal => configuration == RhiConfigProfile::RepoLocal, + }; + matches + .then_some(()) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Profile)) +} + +fn normalized_config_digest( + profile: RhiConfigProfile, + normalized: &Value, +) -> Result<RhiNormalizedConfigDigest, RhiStateMetadataError> { + let bytes = serde_json::to_vec(normalized) + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Configuration))?; + let length = u64::try_from(bytes.len()) + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Configuration))?; + let mut hasher = Sha256::new(); + hasher.update(NORMALIZED_CONFIG_DIGEST_DOMAIN); + hasher.update([match profile { + RhiConfigProfile::Production => 0, + RhiConfigProfile::RepoLocal => 1, + }]); + hasher.update(length.to_be_bytes()); + hasher.update(bytes); + Ok(RhiNormalizedConfigDigest(hasher.finalize().into())) +} + +fn evidence_policy_digest( + normalized: &Value, +) -> Result<RhiEvidencePolicyDigest, RhiStateMetadataError> { + let relays = normalized + .pointer("/relays") + .and_then(Value::as_array) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy))?; + let mut relay_urls = BTreeMap::new(); + for relay in relays { + let id = string(relay, "/id")?; + let url = string(relay, "/url")?; + if relay_urls.insert(id, url).is_some() { + return Err(RhiStateMetadataError::new( + RhiStateMetadataErrorKind::EvidencePolicy, + )); + } + } + + let evidence = normalized + .pointer("/evidence") + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy))?; + let sources = evidence + .pointer("/sources") + .and_then(Value::as_array) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy))?; + let mut normalized_sources = Vec::with_capacity(sources.len()); + for source in sources { + let relay_id = string(source, "/relay_id")?; + let relay_url = relay_urls + .get(relay_id) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy))?; + normalized_sources.push(json!({ + "completion": "nostr_eose_before_deadline", + "deadline_ms": integer(source, "/deadline_ms")?, + "kind": string(source, "/kind")?, + "lookback_seconds": integer(source, "/lookback_seconds")?, + "overlap_seconds": integer(source, "/overlap_seconds")?, + "relay_id": relay_id, + "relay_url": relay_url, + "required": boolean(source, "/required")?, + "selector": string(source, "/selector")?, + "source_id": string(source, "/source_id")?, + })); + } + normalized_sources + .sort_by(|left, right| left["source_id"].as_str().cmp(&right["source_id"].as_str())); + let policy = json!({ + "contract": string(evidence, "/contract")?, + "contract_version": integer(evidence, "/contract_version")?, + "policy_id": string(evidence, "/policy_id")?, + "sources": normalized_sources, + }); + let bytes = serde_json::to_vec(&policy) + .map_err(|_| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy))?; + let mut hasher = Sha256::new(); + hasher.update(EVIDENCE_POLICY_DIGEST_DOMAIN); + hasher.update(bytes); + Ok(RhiEvidencePolicyDigest(hasher.finalize().into())) +} + +fn expected_identity( + normalized: &Value, +) -> Result<RhiExpectedPublicIdentity, RhiStateMetadataError> { + normalized + .pointer("/identity/service/expected_public_key") + .and_then(Value::as_str) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::Identity)) + .and_then(RhiExpectedPublicIdentity::from_hex) +} + +fn string<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, RhiStateMetadataError> { + value + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy)) +} + +fn integer(value: &Value, pointer: &str) -> Result<u64, RhiStateMetadataError> { + value + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy)) +} + +fn boolean(value: &Value, pointer: &str) -> Result<bool, RhiStateMetadataError> { + value + .pointer(pointer) + .and_then(Value::as_bool) + .ok_or_else(|| RhiStateMetadataError::new(RhiStateMetadataErrorKind::EvidencePolicy)) +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -62,7 +62,7 @@ fn shared_service_sqlite_is_the_only_catalog_authority() { } #[test] -fn shared_storage_test_evidence_is_exactly_source_locked() { +fn shared_storage_generation_type_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 }" )); diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs @@ -1,19 +1,19 @@ #![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 std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path}; -use radroots_service_sqlite::{ - MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceDatabaseMetadata, - ServiceSqliteApplicationId, ServiceSqlitePaths, -}; +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; 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, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile, + RhiStateHostErrorKind, RhiStateHostMode, RhiStateMetadata, initialize_rhi_state, + open_rhi_state_inspection, open_rhi_state_read_write, parse_rhi_cli_v1_from, + parse_rhi_config_v1, resolve_rhi_runtime_context, }; +const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); const LIB_SOURCE: &str = include_str!("../src/lib.rs"); @@ -43,14 +43,14 @@ fn prepare_state_directory(runtime: &rhi::RhiRuntimeContext) { 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, +fn metadata(runtime: &rhi::RhiRuntimeContext) -> RhiStateMetadata { + let configuration = parse_rhi_config_v1(EXAMPLE.as_bytes(), RhiConfigProfile::RepoLocal) + .expect("configuration"); + RhiStateMetadata::new( + runtime, + &configuration, 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") } @@ -80,7 +80,6 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( 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(); @@ -105,7 +104,7 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( assert_eq!(duplicate.kind(), RhiStateHostErrorKind::Initialize); let (applied_at, build) = migration_evidence(); - let writer = open_rhi_state_read_write(&runtime, &identity, applied_at, &build) + let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) .await .expect("existing writable state"); assert_eq!(writer.mode(), RhiStateHostMode::ReadWriteExisting); @@ -114,20 +113,20 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( "RhiStateHost { mode: ReadWriteExisting, state: \"[sealed]\" }" ); - let contended = open_rhi_state_inspection(&runtime, &identity) + let contended = open_rhi_state_inspection(&runtime, &metadata) .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) + let inspection = open_rhi_state_inspection(&runtime, &metadata) .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) + let writer = open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) .await .expect("authority reacquisition after explicit close"); writer.close().await.expect("reopened writer close"); @@ -140,10 +139,9 @@ async fn missing_state_and_mismatched_evidence_fail_before_database_creation() { 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) + let missing = open_rhi_state_read_write(&primary, &primary_metadata, applied_at, &build) .await .expect_err("missing state is never created by open"); assert_eq!(missing.kind(), RhiStateHostErrorKind::ReadWriteOpen); diff --git a/tests/services_hardening_state_metadata.rs b/tests/services_hardening_state_metadata.rs @@ -0,0 +1,188 @@ +#![forbid(unsafe_code)] + +use std::{error::Error, path::Path}; + +use radroots_storage::event::SourceGeneration; +use rhi::{ + RHI_ADMIN_CONTRACT_VERSION, RHI_CONFIG_SCHEMA_VERSION, RHI_PROVIDER_CONTRACT_VERSION, + RHI_STATE_APPLICATION_ID, RHI_STATE_SCHEMA_VERSION, RHI_STATUS_CONTRACT_VERSION, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile, + RhiStateMetadata, RhiStateMetadataErrorKind, parse_rhi_cli_v1_from, parse_rhi_config_v1, + resolve_rhi_runtime_context, +}; + +const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const METADATA_SOURCE: &str = include_str!("../src/state_metadata.rs"); + +fn runtime(root: &Path, profile: &str) -> rhi::RhiRuntimeContext { + let root = root.to_str().expect("UTF-8 temporary root"); + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + profile, + "--instance", + "primary", + "--repo-local-root", + root, + "run", + ]) + .expect("valid test invocation"); + resolve_rhi_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context") +} + +fn state_metadata( + runtime: &rhi::RhiRuntimeContext, + source: &str, +) -> Result<RhiStateMetadata, rhi::RhiStateMetadataError> { + let configuration = + parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("configuration"); + RhiStateMetadata::new( + runtime, + &configuration, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) +} + +#[test] +fn exact_database_configuration_identity_and_policy_bindings_are_frozen() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let metadata = state_metadata(&runtime, EXAMPLE).expect("state metadata"); + let database = metadata.database(); + + assert_eq!(RHI_STATE_APPLICATION_ID.to_be_bytes(), *b"RDRH"); + assert_eq!(database.application_id().get(), RHI_STATE_APPLICATION_ID); + assert_eq!(database.service().as_str(), "rhi"); + assert_eq!(database.instance().as_str(), "primary"); + assert_eq!(database.source_generation().as_bytes(), &[0x5a; 32]); + assert_eq!( + database.state_schema_version().get(), + RHI_STATE_SCHEMA_VERSION + ); + assert_eq!(database.created_at_unix_ms(), 1_725_000_000_000); + assert_eq!(metadata.expected_identity().as_hex(), "2".repeat(64)); + + let versions = metadata.policy_versions(); + assert_eq!(versions.configuration(), RHI_CONFIG_SCHEMA_VERSION); + assert_eq!(versions.state(), RHI_STATE_SCHEMA_VERSION); + assert_eq!(versions.admin(), RHI_ADMIN_CONTRACT_VERSION); + assert_eq!(versions.status(), RHI_STATUS_CONTRACT_VERSION); + assert_eq!(versions.provider(), RHI_PROVIDER_CONTRACT_VERSION); + assert_eq!( + lower_hex(metadata.configuration_digest().as_bytes()), + "7950e77614e1302f673d3434a58a69bfb4ce9c8006b61b29a12deb2b729136d4" + ); + assert_eq!( + lower_hex(metadata.evidence_policy_digest().as_bytes()), + "43f083e29fcff4f90e66b546c49f74b0205ecb148beb352adb5a211d3e5f86b2" + ); +} + +#[test] +fn digests_use_fully_defaulted_values_and_change_with_normalized_policy() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let explicit = state_metadata(&runtime, EXAMPLE).expect("explicit defaults"); + let implicit_source = EXAMPLE + .replace("shutdown_grace_ms = 30000\n", "") + .replace("level = \"info\"\n", "") + .replace("format = \"json\"\n", "") + .replace("busy_timeout_ms = 5000\n", "") + .replace("max_connections = 8\n", ""); + let implicit = state_metadata(&runtime, &implicit_source).expect("implicit defaults"); + assert_eq!( + explicit.configuration_digest(), + implicit.configuration_digest() + ); + assert_eq!( + explicit.evidence_policy_digest(), + implicit.evidence_policy_digest() + ); + + let changed = state_metadata( + &runtime, + &EXAMPLE.replace("deadline_ms = 10000", "deadline_ms = 10001"), + ) + .expect("changed policy"); + assert_ne!( + explicit.configuration_digest(), + changed.configuration_digest() + ); + assert_ne!( + explicit.evidence_policy_digest(), + changed.evidence_policy_digest() + ); +} + +#[test] +fn profile_binding_fails_closed_without_state_or_source_disclosure() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let production = parse_rhi_config_v1(EXAMPLE.as_bytes(), RhiConfigProfile::Production) + .expect("production configuration"); + let error = RhiStateMetadata::new( + &runtime, + &production, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1, + ) + .expect_err("profile mismatch"); + assert_eq!(error.kind(), RhiStateMetadataErrorKind::Profile); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); + assert!(!rendered.contains(&"2".repeat(64))); +} + +#[test] +fn metadata_debug_and_package_boundary_disclose_no_values() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let metadata = state_metadata(&runtime, EXAMPLE).expect("state metadata"); + let rendered = format!("{metadata:?}"); + for forbidden in [ + directory.path().to_string_lossy().as_ref(), + &"2".repeat(64), + &lower_hex(metadata.configuration_digest().as_bytes()), + &lower_hex(metadata.evidence_policy_digest().as_bytes()), + ] { + assert!(!rendered.contains(forbidden)); + } + + assert!(LIB_SOURCE.contains("mod state_metadata;")); + assert!(!LIB_SOURCE.contains("pub mod state_metadata;")); + for forbidden in [ + "sqlx::", + "rusqlite", + "CREATE TABLE", + "INSERT INTO", + "UPDATE ", + "DELETE FROM", + "std::fs", + "std::env", + "std::time", + "Serialize", + "Deserialize", + ] { + assert!( + !METADATA_SOURCE.contains(forbidden), + "found forbidden metadata authority `{forbidden}`" + ); + } +} + +fn lower_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output +}