commit ff6f54ec8976e3d05bc2ca15d5de6d816747c402
parent cc7fb52923af03e48d875af908723da2e3339e50
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 20:51:55 +0000
rhi: bind state metadata
Diffstat:
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
+}