commit 681277a35eb026989eb68fd5c7defbd36a30e4c9
parent 7ffa05d3f71ac35fec30302db26d2beac87982c2
Author: triesap <tyson@radroots.org>
Date: Fri, 21 Aug 2026 13:43:03 +0000
myc: seal SQLite host lifecycle
Diffstat:
7 files changed, 515 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1546,6 +1546,7 @@ dependencies = [
"radroots_secrets",
"radroots_service_sqlite",
"radroots_signing",
+ "radroots_storage",
"rand 0.9.2",
"serde",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
@@ -65,5 +65,6 @@ zeroize = "1.8"
[dev-dependencies]
futures-util = "0.3.32"
+radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", default-features = false }
serial_test = "3"
tokio-tungstenite = "0.26.2"
diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml
@@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e
version = "0.1.0-alpha"
source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379"
lockfile = "Cargo.lock"
-lockfile_sha256 = "d546a6b89f6cf896d410da6b45b48454594cef208ac85f93892289d49eb04035"
+lockfile_sha256 = "8dfe6eb163aafac545e3e23110f4ccd0822a41dbbac8df441a5cedc85e75cce4"
diff --git a/src/lib.rs b/src/lib.rs
@@ -26,6 +26,7 @@ pub mod signer;
mod signing_adapter;
pub mod sql;
mod state_catalog;
+mod state_host;
pub mod transport;
pub use app::{
@@ -111,4 +112,8 @@ pub use state_catalog::{
MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog,
validate_myc_state_catalogs,
};
+pub use state_host::{
+ MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state,
+ open_myc_state_inspection, open_myc_state_read_write,
+};
pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot};
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -0,0 +1,313 @@
+//! Sealed lifecycle boundary for the canonical Myc 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::{
+ MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, myc_migration_catalog, myc_schema_catalog,
+ validate_myc_state_catalogs,
+};
+
+/// Stable lifecycle mode of one opened Myc state host.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycStateHostMode {
+ ReadWriteExisting,
+ ReadOnlyInspection,
+}
+
+/// Stable source-free class for a Myc state-host lifecycle failure.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycStateHostErrorKind {
+ InvalidPaths,
+ InvalidEvidence,
+ Catalog,
+ Initialize,
+ ReadWriteOpen,
+ InspectionOpen,
+ Close,
+}
+
+impl MycStateHostErrorKind {
+ /// 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 Myc state-host lifecycle failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycStateHostError {
+ kind: MycStateHostErrorKind,
+}
+
+impl MycStateHostError {
+ const fn new(kind: MycStateHostErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable failure class.
+ #[must_use]
+ pub const fn kind(self) -> MycStateHostErrorKind {
+ 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 MycStateHostError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ MycStateHostErrorKind::InvalidPaths => "Myc state paths are invalid",
+ MycStateHostErrorKind::InvalidEvidence => "Myc state identity evidence is invalid",
+ MycStateHostErrorKind::Catalog => "Myc state catalogs are invalid",
+ MycStateHostErrorKind::Initialize => "Myc state initialization failed",
+ MycStateHostErrorKind::ReadWriteOpen => "Myc writable state could not be opened",
+ MycStateHostErrorKind::InspectionOpen => "Myc inspection state could not be opened",
+ MycStateHostErrorKind::Close => "Myc state host could not be closed",
+ })
+ }
+}
+
+impl fmt::Debug for MycStateHostError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycStateHostError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl Error for MycStateHostError {}
+
+/// One opened Myc state catalog whose raw SQLite authority remains sealed.
+///
+/// Callers cannot construct the wrapper or extract the shared host:
+///
+/// ```compile_fail
+/// use myc::{MycStateHost, MycStateHostMode};
+///
+/// let _ = MycStateHost {
+/// host: todo!(),
+/// mode: MycStateHostMode::ReadWriteExisting,
+/// };
+/// ```
+///
+/// The wrapper intentionally exposes no transaction or connection escape:
+///
+/// ```compile_fail
+/// use myc::MycStateHost;
+///
+/// fn bypass(host: &MycStateHost) {
+/// let _ = host.transaction(|_| async { Ok::<_, ()>(()) });
+/// }
+/// ```
+pub struct MycStateHost {
+ host: ServiceSqliteHost,
+ mode: MycStateHostMode,
+}
+
+impl MycStateHost {
+ /// Returns the lifecycle mode selected when this host was opened.
+ #[must_use]
+ pub const fn mode(&self) -> MycStateHostMode {
+ self.mode
+ }
+
+ /// Drains the shared host and explicitly releases retained authority.
+ pub async fn close(&self) -> Result<(), MycStateHostError> {
+ self.host
+ .close()
+ .await
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Close))
+ }
+}
+
+impl fmt::Debug for MycStateHost {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycStateHost")
+ .field("mode", &self.mode)
+ .field("state", &"[sealed]")
+ .finish()
+ }
+}
+
+/// Creates a missing Myc catalog exactly once and releases initialization authority.
+///
+/// This function never opens an existing database as initialization. The caller
+/// injects the shared metadata evidence; the Myc metadata-binding layer owns
+/// its exact application and configuration bindings.
+pub async fn initialize_myc_state(
+ runtime: &MycRuntimeContext,
+ metadata: &ServiceDatabaseMetadata,
+) -> Result<(), MycStateHostError> {
+ 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(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?;
+ authority
+ .release()
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?;
+ drop(migrations);
+ Ok(())
+}
+
+/// Opens an already initialized Myc 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_myc_state_read_write(
+ runtime: &MycRuntimeContext,
+ identity: &ServiceDatabaseIdentity,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<MycStateHost, MycStateHostError> {
+ 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(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?;
+ if outcome.initial_version() != MYC_STATE_SCHEMA_VERSION
+ || outcome.final_version() != MYC_STATE_SCHEMA_VERSION
+ || outcome.applied_count() != 0
+ {
+ let _ = host.close().await;
+ return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog));
+ }
+ Ok(MycStateHost {
+ host,
+ mode: MycStateHostMode::ReadWriteExisting,
+ })
+}
+
+/// Opens an already initialized Myc catalog for immutable inspection.
+pub async fn open_myc_state_inspection(
+ runtime: &MycRuntimeContext,
+ identity: &ServiceDatabaseIdentity,
+) -> Result<MycStateHost, MycStateHostError> {
+ 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(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?;
+ Ok(MycStateHost {
+ host,
+ mode: MycStateHostMode::ReadOnlyInspection,
+ })
+}
+
+fn state_paths(runtime: &MycRuntimeContext) -> Result<ServiceSqlitePaths, MycStateHostError> {
+ ServiceSqlitePaths::from_runtime_context(runtime.context())
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidPaths))
+}
+
+fn require_metadata(
+ runtime: &MycRuntimeContext,
+ metadata: &ServiceDatabaseMetadata,
+) -> Result<(), MycStateHostError> {
+ let matches = metadata.service() == runtime.context().service()
+ && metadata.instance() == runtime.context().instance()
+ && metadata.state_schema_version().get() == MYC_STATE_SCHEMA_VERSION;
+ matches
+ .then_some(())
+ .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))
+}
+
+fn require_identity(
+ runtime: &MycRuntimeContext,
+ identity: &ServiceDatabaseIdentity,
+) -> Result<(), MycStateHostError> {
+ let matches = identity.service() == runtime.context().service()
+ && identity.instance() == runtime.context().instance()
+ && identity.supported_state_schema_version().get() == MYC_STATE_SCHEMA_VERSION;
+ matches
+ .then_some(())
+ .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))
+}
+
+fn catalogs() -> Result<
+ (
+ radroots_service_sqlite::MigrationCatalog,
+ radroots_service_sqlite::SchemaCatalog,
+ ),
+ MycStateHostError,
+> {
+ let migrations = myc_migration_catalog()
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?;
+ let schema =
+ myc_schema_catalog().map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?;
+ validate_myc_state_catalogs(&migrations, &schema)
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::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("Myc 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
@@ -41,6 +41,13 @@ fn shared_service_sqlite_is_exactly_pinned_to_the_source_locked_lib() {
}
#[test]
+fn shared_storage_test_evidence_is_exactly_pinned_to_the_source_locked_lib() {
+ assert!(MANIFEST.contains(
+ "radroots_storage = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\", default-features = false }"
+ ));
+}
+
+#[test]
fn release_acceptance_checks_both_feature_profiles() {
assert!(
RELEASE_ACCEPTANCE.contains("cargo check --locked --all-targets --no-default-features\n")
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 myc::{
+ MYC_STATE_SCHEMA_VERSION, MycStateHostErrorKind, MycStateHostMode, RadrootsHostEnvironment,
+ RadrootsPathResolver, RadrootsPlatform, initialize_myc_state, open_myc_state_inspection,
+ open_myc_state_read_write, parse_myc_cli_v1_from, resolve_myc_runtime_context,
+};
+use radroots_service_sqlite::{
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceDatabaseMetadata,
+ ServiceSqliteApplicationId, ServiceSqlitePaths,
+};
+use radroots_storage::event::SourceGeneration;
+
+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) -> myc::MycRuntimeContext {
+ let root = root.to_str().expect("UTF-8 temporary root");
+ let invocation = parse_myc_cli_v1_from([
+ "myc",
+ "--profile",
+ "repo-local",
+ "--instance",
+ instance,
+ "--repo-local-root",
+ root,
+ "run",
+ ])
+ .expect("valid test invocation");
+ resolve_myc_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime context")
+}
+
+fn prepare_state_directory(runtime: &myc::MycRuntimeContext) {
+ 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: &myc::MycRuntimeContext) -> ServiceDatabaseMetadata {
+ let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("SQLite paths");
+ ServiceDatabaseMetadata::new(
+ &paths,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ NonZeroU32::new(MYC_STATE_SCHEMA_VERSION).expect("schema version"),
+ 1_725_000_000_000,
+ ServiceSqliteApplicationId::new(0x4d59_4331).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",
+ "b44119fbac5985be8127ad1bf56d2950e6399427",
+ "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_myc_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_myc_state(&runtime, &metadata)
+ .await
+ .expect_err("second initialization must fail");
+ assert_eq!(duplicate.kind(), MycStateHostErrorKind::Initialize);
+
+ let (applied_at, build) = migration_evidence();
+ let writer = open_myc_state_read_write(&runtime, &identity, applied_at, &build)
+ .await
+ .expect("existing writable state");
+ assert_eq!(writer.mode(), MycStateHostMode::ReadWriteExisting);
+ assert_eq!(
+ format!("{writer:?}"),
+ "MycStateHost { mode: ReadWriteExisting, state: \"[sealed]\" }"
+ );
+
+ let contended = open_myc_state_inspection(&runtime, &identity)
+ .await
+ .expect_err("inspection must not bypass active writer authority");
+ assert_eq!(contended.kind(), MycStateHostErrorKind::InspectionOpen);
+ writer.close().await.expect("writer close");
+ writer.close().await.expect("idempotent writer close");
+
+ let inspection = open_myc_state_inspection(&runtime, &identity)
+ .await
+ .expect("existing inspection state");
+ assert_eq!(inspection.mode(), MycStateHostMode::ReadOnlyInspection);
+ inspection.close().await.expect("inspection close");
+
+ let writer = open_myc_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_myc_state_read_write(&primary, &primary_identity, applied_at, &build)
+ .await
+ .expect_err("missing state is never created by open");
+ assert_eq!(missing.kind(), MycStateHostErrorKind::ReadWriteOpen);
+ assert!(!primary.artifacts().state_database().exists());
+
+ let mismatch = initialize_myc_state(&secondary, &primary_metadata)
+ .await
+ .expect_err("cross-instance metadata");
+ assert_eq!(mismatch.kind(), MycStateHostErrorKind::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}`"
+ );
+ }
+}