commit c45fd07cb6e920c71dabd2d90a85f490d30e7647
parent 1be8aa782864713f5011a2f0298e24262551c634
Author: triesap <tyson@radroots.org>
Date: Fri, 21 Aug 2026 14:33:40 +0000
state: seal Myc metadata repository
- add the governed schema-v2 migration and immutable metadata binding\n- route repository mutation through ServiceSqliteTransaction\n- verify exact binding before writable or inspection host exposure
Diffstat:
10 files changed, 1037 insertions(+), 106 deletions(-)
diff --git a/README b/README
@@ -43,6 +43,23 @@ host-environment resolver, worker namespace, ambient selector, or implicit
path default. An explicit absolute `--config` may select the document to read
without changing the canonical common artifact inventory.
+## Governed state boundary
+
+Create-new initialization reserves the shared schema-v1 metadata and migration
+ledger, retains exclusive writer authority, applies the exact Myc schema-v2
+migration, binds the normalized configuration, expected identity roles, and
+policy versions through a sealed typed repository, and explicitly closes the
+host before reporting success. Existing writable open can resume the exact
+v1-to-v2 prefix; read-only inspection requires the current catalog and exact
+immutable Myc binding.
+
+The public Myc repository exposes no raw pool, connection, transaction-control
+handle, path, or SQL. Its only SQLite mutation executes inside the shared
+`ServiceSqliteTransaction` runner. Provider and relay work cannot occur inside
+that transaction boundary. The v2 binding table and its update/delete guards
+are checksum-pinned service-owned schema objects; later workflow tables remain
+owned by their ordered repository steps.
+
## NIP-46 runtime contract
Myc listens for encrypted kind-24133 requests on the exact configured relay
diff --git a/src/lib.rs b/src/lib.rs
@@ -28,6 +28,7 @@ pub mod sql;
mod state_catalog;
mod state_host;
mod state_metadata;
+mod state_repository;
pub mod transport;
pub use app::{
@@ -108,8 +109,10 @@ pub use runtime_context::{
resolve_myc_runtime_context,
};
pub use state_catalog::{
- MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_SCHEMA_CATALOG_SHA256, MYC_STATE_SCHEMA_VERSION,
- MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_1_SHA256,
+ MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_CATALOG_SHA256,
+ MYC_STATE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT,
+ MYC_STATE_SCHEMA_VERSION_1_SHA256, MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256,
+ MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_2_SHA256,
MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog,
validate_myc_state_catalogs,
};
@@ -122,4 +125,7 @@ pub use state_metadata::{
MycExpectedPublicIdentity, MycNormalizedConfigDigest, MycStateMetadata, MycStateMetadataError,
MycStateMetadataErrorKind, MycStatePolicyVersions,
};
+pub use state_repository::{
+ MycStateRepository, MycStateRepositoryError, MycStateRepositoryErrorKind,
+};
pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot};
diff --git a/src/state_catalog.rs b/src/state_catalog.rs
@@ -4,20 +4,21 @@ use core::fmt;
use std::error::Error;
use radroots_service_sqlite::{
- MigrationCatalog, SchemaCatalog, SchemaDigest, SchemaVersionCatalog,
+ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest,
+ SchemaObject, SchemaObjectKind, SchemaVersionCatalog,
};
-/// The clean-slate Myc baseline schema version.
-pub const MYC_STATE_SCHEMA_VERSION: u32 = 1;
+/// The shared create-new baseline written before service migrations run.
+pub const MYC_STATE_BASE_SCHEMA_VERSION: u32 = 1;
+
+/// The newest governed Myc state schema understood by this binary.
+pub const MYC_STATE_SCHEMA_VERSION: u32 = 2;
/// The shared metadata and migration-ledger objects present at schema v1.
pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6;
-/// SHA-256 identity of the empty schema-v1 migration catalog.
-pub const MYC_MIGRATION_CATALOG_SHA256: [u8; 32] = [
- 0xec, 0x89, 0xdc, 0x8f, 0x7b, 0x6c, 0x2a, 0x11, 0xb9, 0x67, 0xe3, 0x38, 0x08, 0xe4, 0x03, 0x1e,
- 0x29, 0xb3, 0x97, 0x0f, 0xfe, 0xe4, 0x95, 0x9b, 0xff, 0x9b, 0xad, 0x35, 0x28, 0x77, 0xee, 0x9b,
-];
+/// The shared objects plus the three immutable Myc metadata objects at schema v2.
+pub const MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT: u32 = 9;
/// SHA-256 identity of the exact schema-v1 object snapshot.
pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [
@@ -25,12 +26,108 @@ pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [
0x11, 0x9f, 0x5b, 0xd9, 0x2b, 0x04, 0x39, 0x0c, 0x67, 0xf6, 0x98, 0xa0, 0x36, 0xfa, 0x78, 0xae,
];
+/// SHA-256 identity of the schema-v2 object snapshot.
+pub const MYC_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] = [
+ 0x94, 0x73, 0x9b, 0x5a, 0x34, 0xca, 0x8e, 0xd1, 0x30, 0xb9, 0x46, 0xd0, 0x92, 0x73, 0x1b, 0x59,
+ 0xbf, 0x15, 0x48, 0xfe, 0x54, 0x1d, 0x9a, 0x92, 0x69, 0xa3, 0x3d, 0x0a, 0xed, 0x1e, 0xa0, 0x9e,
+];
+
+/// SHA-256 identity of the ordered Myc migration catalog.
+pub const MYC_MIGRATION_CATALOG_SHA256: [u8; 32] = [
+ 0xbe, 0x15, 0x58, 0x4e, 0x4e, 0x6f, 0xe1, 0xf5, 0xb8, 0x02, 0x09, 0xe8, 0xf6, 0x12, 0x5e, 0xcc,
+ 0x92, 0x81, 0xfc, 0x22, 0xe9, 0x76, 0x9a, 0x79, 0xf2, 0x10, 0xc3, 0x0c, 0x43, 0x1f, 0xf4, 0x62,
+];
+
/// SHA-256 identity of the schema catalog bound to the migration catalog.
pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [
- 0x23, 0x09, 0x15, 0x3f, 0x3b, 0x49, 0x75, 0x48, 0x87, 0xc5, 0x48, 0xa7, 0x45, 0x9b, 0x3e, 0x09,
- 0x09, 0x9c, 0x60, 0xf7, 0x14, 0x6b, 0x37, 0x3c, 0x8f, 0x96, 0x70, 0x6c, 0x67, 0x68, 0xd7, 0x91,
+ 0x67, 0x3f, 0x8b, 0xa2, 0x09, 0x5e, 0xe0, 0x2e, 0x80, 0x48, 0xaf, 0x85, 0x0d, 0x29, 0x44, 0x36,
+ 0xcb, 0x7b, 0x81, 0x50, 0xe9, 0x16, 0x93, 0xb7, 0x72, 0x7f, 0xae, 0x05, 0x81, 0x2e, 0x83, 0x1c,
+];
+
+/// SHA-256 identity of the schema-v2 migration content.
+pub const MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] = [
+ 0xc5, 0xeb, 0x97, 0x8b, 0xda, 0x1b, 0xc7, 0x0c, 0x70, 0xbd, 0x9b, 0xd4, 0x4d, 0x8c, 0xba, 0x70,
+ 0xcb, 0x28, 0x57, 0xd2, 0x6a, 0x9d, 0xce, 0x74, 0x96, 0x1f, 0x4b, 0x78, 0x1e, 0x71, 0x00, 0x37,
];
+/// SHA-256 identity of the Myc metadata table definition.
+const MYC_STATE_METADATA_TABLE_SHA256: [u8; 32] = [
+ 0x16, 0x17, 0x46, 0xa2, 0x26, 0x42, 0x46, 0x2f, 0x2b, 0xdb, 0x08, 0x5b, 0xae, 0xde, 0xb2, 0x3b,
+ 0xb2, 0x83, 0xee, 0xbe, 0x8b, 0xcc, 0x95, 0x72, 0x38, 0xad, 0xaa, 0x30, 0x78, 0xc0, 0x29, 0x1a,
+];
+
+/// SHA-256 identity of the Myc metadata update guard.
+const MYC_STATE_METADATA_NO_UPDATE_SHA256: [u8; 32] = [
+ 0xf0, 0xe3, 0x30, 0xf2, 0x19, 0x63, 0xd4, 0x94, 0xf8, 0x02, 0xf3, 0x55, 0x78, 0x4b, 0x45, 0x1c,
+ 0xde, 0x2b, 0xd2, 0xc8, 0x0d, 0x90, 0x22, 0x33, 0x0e, 0x61, 0x03, 0x97, 0xd4, 0x3c, 0xbe, 0x08,
+];
+
+/// SHA-256 identity of the Myc metadata delete guard.
+const MYC_STATE_METADATA_NO_DELETE_SHA256: [u8; 32] = [
+ 0x05, 0x32, 0x87, 0x93, 0x6d, 0xbb, 0xae, 0x52, 0x0b, 0xff, 0x25, 0xfe, 0x87, 0xd5, 0xd2, 0xd1,
+ 0xa3, 0xcc, 0xf2, 0x81, 0xc3, 0x5b, 0x14, 0xa0, 0x24, 0xa7, 0x74, 0xa5, 0x67, 0x50, 0x3d, 0x4c,
+];
+
+macro_rules! myc_state_metadata_table_sql {
+ () => {
+ r#"CREATE TABLE myc_state_metadata (
+ singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1),
+ normalized_config_sha256 BLOB NOT NULL CHECK (length(normalized_config_sha256) = 32),
+ transport_public_key TEXT NOT NULL
+ CHECK (length(CAST(transport_public_key AS BLOB)) = 64)
+ CHECK (transport_public_key NOT GLOB '*[^0-9a-f]*'),
+ user_public_key TEXT NOT NULL
+ CHECK (length(CAST(user_public_key AS BLOB)) = 64)
+ CHECK (user_public_key NOT GLOB '*[^0-9a-f]*'),
+ discovery_public_key TEXT
+ CHECK (discovery_public_key IS NULL OR (
+ length(CAST(discovery_public_key AS BLOB)) = 64
+ AND discovery_public_key NOT GLOB '*[^0-9a-f]*'
+ )),
+ config_contract_version INTEGER NOT NULL
+ CHECK (config_contract_version BETWEEN 1 AND 4294967295),
+ state_contract_version INTEGER NOT NULL
+ CHECK (state_contract_version BETWEEN 1 AND 4294967295),
+ operator_contract_version INTEGER NOT NULL
+ CHECK (operator_contract_version BETWEEN 1 AND 4294967295),
+ status_contract_version INTEGER NOT NULL
+ CHECK (status_contract_version BETWEEN 1 AND 4294967295)
+) STRICT"#
+ };
+}
+
+macro_rules! myc_state_metadata_no_update_sql {
+ () => {
+ r#"CREATE TRIGGER myc_state_metadata_no_update
+BEFORE UPDATE ON myc_state_metadata
+BEGIN
+ SELECT RAISE(ABORT, 'Myc state metadata is immutable');
+END"#
+ };
+}
+
+macro_rules! myc_state_metadata_no_delete_sql {
+ () => {
+ r#"CREATE TRIGGER myc_state_metadata_no_delete
+BEFORE DELETE ON myc_state_metadata
+BEGIN
+ SELECT RAISE(ABORT, 'Myc state metadata is immutable');
+END"#
+ };
+}
+
+const CREATE_MYC_STATE_METADATA_TABLE_SQL: &str = myc_state_metadata_table_sql!();
+const CREATE_MYC_STATE_METADATA_NO_UPDATE_SQL: &str = myc_state_metadata_no_update_sql!();
+const CREATE_MYC_STATE_METADATA_NO_DELETE_SQL: &str = myc_state_metadata_no_delete_sql!();
+
+const CREATE_MYC_STATE_METADATA_MIGRATION_SQL: &str = concat!(
+ myc_state_metadata_table_sql!(),
+ ";\n",
+ myc_state_metadata_no_update_sql!(),
+ ";\n",
+ myc_state_metadata_no_delete_sql!(),
+);
+
/// Stable classes for invalid embedded Myc catalog definitions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MycStateCatalogErrorKind {
@@ -68,7 +165,7 @@ impl MycStateCatalogError {
self.kind
}
- /// Returns the stable machine-readable code.
+ /// Returns the stable machine-readable failure code.
#[must_use]
pub const fn code(self) -> &'static str {
self.kind.code()
@@ -100,12 +197,19 @@ impl fmt::Debug for MycStateCatalogError {
impl Error for MycStateCatalogError {}
-/// Constructs the exact schema-v1 migration catalog.
+/// Constructs the exact ordered Myc migration catalog.
pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> {
- let catalog = MigrationCatalog::new([])
+ let migration = MigrationDescriptor::sql(
+ MYC_STATE_SCHEMA_VERSION,
+ "create_myc_state_metadata",
+ CREATE_MYC_STATE_METADATA_MIGRATION_SQL,
+ MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
+ let catalog = MigrationCatalog::new([migration])
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
if catalog.current_version() != MYC_STATE_SCHEMA_VERSION
- || !catalog.descriptors().is_empty()
+ || catalog.descriptors().len() != 1
|| catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256
{
return Err(MycStateCatalogError::new(
@@ -118,32 +222,73 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError>
/// Constructs the exact Myc schema catalog bound to the migration catalog.
pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> {
let migrations = myc_migration_catalog()?;
- let version = SchemaVersionCatalog::new(
- MYC_STATE_SCHEMA_VERSION,
+ let version_one = SchemaVersionCatalog::new(
+ MYC_STATE_BASE_SCHEMA_VERSION,
[],
SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_1_SHA256),
)
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
- let catalog = SchemaCatalog::new(&migrations, [version])
+ let version_two = SchemaVersionCatalog::new(
+ MYC_STATE_SCHEMA_VERSION,
+ myc_state_metadata_objects()?,
+ SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_2_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
+ let catalog = SchemaCatalog::new(&migrations, [version_one, version_two])
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
validate_myc_state_catalogs(&migrations, &catalog)?;
Ok(catalog)
}
+fn myc_state_metadata_objects() -> Result<[SchemaObject; 3], MycStateCatalogError> {
+ let table = SchemaObject::new(
+ SchemaObjectKind::Table,
+ "myc_state_metadata",
+ "myc_state_metadata",
+ CREATE_MYC_STATE_METADATA_TABLE_SQL,
+ SchemaDigest::from_bytes(MYC_STATE_METADATA_TABLE_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
+ let update = SchemaObject::new(
+ SchemaObjectKind::Trigger,
+ "myc_state_metadata_no_update",
+ "myc_state_metadata",
+ CREATE_MYC_STATE_METADATA_NO_UPDATE_SQL,
+ SchemaDigest::from_bytes(MYC_STATE_METADATA_NO_UPDATE_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
+ let delete = SchemaObject::new(
+ SchemaObjectKind::Trigger,
+ "myc_state_metadata_no_delete",
+ "myc_state_metadata",
+ CREATE_MYC_STATE_METADATA_NO_DELETE_SQL,
+ SchemaDigest::from_bytes(MYC_STATE_METADATA_NO_DELETE_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
+ Ok([table, update, delete])
+}
+
/// Independently validates exact catalog versions, counts, and digests.
pub fn validate_myc_state_catalogs(
migrations: &MigrationCatalog,
schema: &SchemaCatalog,
) -> Result<(), MycStateCatalogError> {
let versions = schema.versions();
+ let descriptors = migrations.descriptors();
let valid = migrations.current_version() == MYC_STATE_SCHEMA_VERSION
- && migrations.descriptors().is_empty()
+ && descriptors.len() == 1
+ && descriptors[0].target_version() == MYC_STATE_SCHEMA_VERSION
+ && descriptors[0].name().as_str() == "create_myc_state_metadata"
+ && descriptors[0].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256
&& migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256
&& schema.migration_catalog_digest() == migrations.digest()
- && versions.len() == 1
- && versions[0].version() == MYC_STATE_SCHEMA_VERSION
+ && versions.len() == 2
+ && versions[0].version() == MYC_STATE_BASE_SCHEMA_VERSION
&& versions[0].object_count() == MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT
&& versions[0].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_1_SHA256
+ && versions[1].version() == MYC_STATE_SCHEMA_VERSION
+ && versions[1].object_count() == MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT
+ && versions[1].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_SHA256
&& schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256;
if valid {
Ok(())
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -4,14 +4,14 @@ use core::fmt;
use std::{error::Error, path::PathBuf};
use radroots_service_sqlite::{
- MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
+ MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database,
};
use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions};
use crate::{
- MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, MycStateMetadata, myc_migration_catalog,
- myc_schema_catalog, validate_myc_state_catalogs,
+ MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, MycStateMetadata,
+ MycStateRepository, myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs,
};
/// Stable lifecycle mode of one opened Myc state host.
@@ -30,6 +30,7 @@ pub enum MycStateHostErrorKind {
Initialize,
ReadWriteOpen,
InspectionOpen,
+ Repository,
Close,
}
@@ -44,6 +45,7 @@ impl MycStateHostErrorKind {
Self::Initialize => "state_initialize_failed",
Self::ReadWriteOpen => "state_read_write_open_failed",
Self::InspectionOpen => "state_inspection_open_failed",
+ Self::Repository => "state_repository_failed",
Self::Close => "state_close_failed",
}
}
@@ -82,6 +84,7 @@ impl fmt::Display for MycStateHostError {
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::Repository => "Myc state repository binding failed",
MycStateHostErrorKind::Close => "Myc state host could not be closed",
})
}
@@ -139,6 +142,12 @@ impl MycStateHost {
&self.metadata
}
+ /// Returns sealed typed repository access bound to this host and metadata.
+ #[must_use]
+ pub const fn repository(&self) -> MycStateRepository<'_> {
+ MycStateRepository::new(&self.host, &self.metadata)
+ }
+
/// Drains the shared host and explicitly releases retained authority.
pub async fn close(&self) -> Result<(), MycStateHostError> {
self.host
@@ -166,24 +175,53 @@ impl fmt::Debug for MycStateHost {
pub async fn initialize_myc_state(
runtime: &MycRuntimeContext,
metadata: &MycStateMetadata,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
) -> Result<(), MycStateHostError> {
let paths = state_paths(runtime)?;
require_metadata(runtime, metadata)?;
+ require_migration_build(metadata, build)?;
let (migrations, schema) = catalogs()?;
- let mut authority = initialize_database(
+ let authority = initialize_database(
&paths,
OpenMode::Initialize,
- metadata.database(),
+ metadata.initial_database_metadata(),
&schema,
initialize_empty_catalog,
)
.await
.map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?;
- authority
- .release()
- .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?;
- drop(migrations);
- Ok(())
+ let identity = metadata.database_identity();
+ let (host, outcome) = ServiceSqliteHost::open_initialized(
+ &paths,
+ &identity,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ authority,
+ applied_at,
+ build,
+ &[],
+ )
+ .await
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?;
+ let state = MycStateHost {
+ host,
+ mode: MycStateHostMode::ReadWriteExisting,
+ metadata: metadata.clone(),
+ };
+ if !exact_initialization_outcome(outcome) {
+ let _ = state.close().await;
+ return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog));
+ }
+ if state.repository().bind_or_verify().await.is_err() {
+ let _ = state.close().await;
+ return Err(MycStateHostError::new(MycStateHostErrorKind::Repository));
+ }
+ state
+ .close()
+ .await
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))
}
/// Opens an already initialized Myc catalog with exclusive writer authority.
@@ -199,6 +237,7 @@ pub async fn open_myc_state_read_write(
) -> Result<MycStateHost, MycStateHostError> {
let paths = state_paths(runtime)?;
require_metadata(runtime, metadata)?;
+ require_migration_build(metadata, build)?;
let identity = metadata.database_identity();
let (migrations, schema) = catalogs()?;
let (host, outcome) = ServiceSqliteHost::open_read_write_existing(
@@ -213,18 +252,20 @@ pub async fn open_myc_state_read_write(
)
.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
- {
+ if !exact_existing_outcome(outcome) {
let _ = host.close().await;
return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog));
}
- Ok(MycStateHost {
+ let state = MycStateHost {
host,
mode: MycStateHostMode::ReadWriteExisting,
metadata: metadata.clone(),
- })
+ };
+ if state.repository().bind_or_verify().await.is_err() {
+ let _ = state.close().await;
+ return Err(MycStateHostError::new(MycStateHostErrorKind::Repository));
+ }
+ Ok(state)
}
/// Opens an already initialized Myc catalog for immutable inspection.
@@ -245,11 +286,16 @@ pub async fn open_myc_state_inspection(
)
.await
.map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?;
- Ok(MycStateHost {
+ let state = MycStateHost {
host,
mode: MycStateHostMode::ReadOnlyInspection,
metadata: metadata.clone(),
- })
+ };
+ if state.repository().verify_binding().await.is_err() {
+ let _ = state.close().await;
+ return Err(MycStateHostError::new(MycStateHostErrorKind::Repository));
+ }
+ Ok(state)
}
fn state_paths(runtime: &MycRuntimeContext) -> Result<ServiceSqlitePaths, MycStateHostError> {
@@ -261,16 +307,48 @@ fn require_metadata(
runtime: &MycRuntimeContext,
metadata: &MycStateMetadata,
) -> Result<(), MycStateHostError> {
- let database = metadata.database();
+ let database = metadata.initial_database_metadata();
+ let identity = metadata.database_identity();
let matches = metadata.matches_runtime(runtime)
&& database.service() == runtime.context().service()
&& database.instance() == runtime.context().instance()
- && database.state_schema_version().get() == MYC_STATE_SCHEMA_VERSION;
+ && database.state_schema_version().get() == MYC_STATE_BASE_SCHEMA_VERSION
+ && 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 require_migration_build(
+ metadata: &MycStateMetadata,
+ build: &MigrationBuildIdentity,
+) -> Result<(), MycStateHostError> {
+ let versions = metadata.policy_versions();
+ let matches = build.config_contract_version() == versions.configuration()
+ && build.state_contract_version() == versions.state()
+ && build.admin_contract_version() == versions.operator()
+ && build.status_contract_version() == versions.status();
matches
.then_some(())
.ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))
}
+fn exact_initialization_outcome(outcome: MigrationApplicationOutcome) -> bool {
+ outcome.initial_version() == MYC_STATE_BASE_SCHEMA_VERSION
+ && outcome.final_version() == MYC_STATE_SCHEMA_VERSION
+ && outcome.applied_count() == 1
+}
+
+fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool {
+ outcome.final_version() == MYC_STATE_SCHEMA_VERSION
+ && matches!(
+ (outcome.initial_version(), outcome.applied_count()),
+ (MYC_STATE_BASE_SCHEMA_VERSION, 1) | (MYC_STATE_SCHEMA_VERSION, 0)
+ )
+}
+
fn catalogs() -> Result<
(
radroots_service_sqlite::MigrationCatalog,
diff --git a/src/state_metadata.rs b/src/state_metadata.rs
@@ -12,8 +12,9 @@ use radroots_storage::event::SourceGeneration;
use sha2::{Digest, Sha256};
use crate::{
- MYC_CONFIG_SCHEMA_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_SCHEMA_VERSION,
- MycBootstrapProfileV1, MycConfigDocumentV1, MycConfigProfile, MycRuntimeContext,
+ MYC_CONFIG_SCHEMA_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_BASE_SCHEMA_VERSION,
+ MYC_STATE_SCHEMA_VERSION, MycBootstrapProfileV1, MycConfigDocumentV1, MycConfigProfile,
+ MycRuntimeContext,
};
const NORMALIZED_CONFIG_DIGEST_DOMAIN: &[u8] = b"radroots.myc.normalized_config.v1\0";
@@ -163,6 +164,7 @@ impl MycStatePolicyVersions {
pub struct MycStateMetadata {
paths: ServiceSqlitePaths,
database: ServiceDatabaseMetadata,
+ database_identity: ServiceDatabaseIdentity,
configuration: MycNormalizedConfigDigest,
identities: MycExpectedIdentities,
policy_versions: MycStatePolicyVersions,
@@ -182,7 +184,7 @@ impl MycStateMetadata {
.map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Paths))?;
let application_id = ServiceSqliteApplicationId::new(MYC_STATE_APPLICATION_ID)
.map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?;
- let state_schema_version = core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION)
+ let state_schema_version = core::num::NonZeroU32::new(MYC_STATE_BASE_SCHEMA_VERSION)
.ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?;
let database = ServiceDatabaseMetadata::new(
&paths,
@@ -192,6 +194,15 @@ impl MycStateMetadata {
application_id,
)
.map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Database))?;
+ let supported_state_schema_version =
+ core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION)
+ .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?;
+ let database_identity = ServiceDatabaseIdentity::new(
+ &paths,
+ source_generation,
+ supported_state_schema_version,
+ application_id,
+ );
let normalized = configuration.normalized();
let configuration = normalized_config_digest(configuration.profile(), normalized)?;
let identities = expected_identities(normalized)?;
@@ -211,22 +222,23 @@ impl MycStateMetadata {
Ok(Self {
paths,
database,
+ database_identity,
configuration,
identities,
policy_versions,
})
}
- /// Returns the shared immutable database metadata.
+ /// Returns the immutable shared schema-v1 initialization metadata.
#[must_use]
- pub const fn database(&self) -> &ServiceDatabaseMetadata {
+ pub const fn initial_database_metadata(&self) -> &ServiceDatabaseMetadata {
&self.database
}
- /// Returns the reopen identity derived from the immutable database metadata.
+ /// Returns the reopen identity with this binary's exact schema ceiling.
#[must_use]
pub fn database_identity(&self) -> ServiceDatabaseIdentity {
- self.database.identity()
+ self.database_identity.clone()
}
/// Returns the normalized configuration digest.
@@ -258,6 +270,7 @@ impl fmt::Debug for MycStateMetadata {
formatter
.debug_struct("MycStateMetadata")
.field("database", &self.database)
+ .field("database_identity", &self.database_identity)
.field("configuration", &self.configuration)
.field("identities", &self.identities)
.field("policy_versions", &self.policy_versions)
diff --git a/src/state_repository.rs b/src/state_repository.rs
@@ -0,0 +1,336 @@
+//! Sealed typed access to Myc-owned SQLite state.
+
+use core::fmt;
+use std::error::Error;
+
+use radroots_service_sqlite::{
+ ServiceSqliteHost, ServiceSqliteTransaction, ServiceSqliteTransactionError,
+ ServiceSqliteTransactionErrorKind,
+};
+use sqlx::Row;
+
+use crate::MycStateMetadata;
+
+const READ_METADATA_SQL: &str = r#"SELECT
+ singleton,
+ CASE
+ WHEN typeof(normalized_config_sha256) = 'blob'
+ AND length(normalized_config_sha256) = 32
+ THEN normalized_config_sha256
+ ELSE NULL
+ END AS normalized_config_sha256,
+ CASE
+ WHEN typeof(transport_public_key) = 'text'
+ AND length(CAST(transport_public_key AS BLOB)) = 64
+ THEN transport_public_key
+ ELSE NULL
+ END AS transport_public_key,
+ CASE
+ WHEN typeof(user_public_key) = 'text'
+ AND length(CAST(user_public_key AS BLOB)) = 64
+ THEN user_public_key
+ ELSE NULL
+ END AS user_public_key,
+ typeof(discovery_public_key) AS discovery_public_key_type,
+ CASE
+ WHEN typeof(discovery_public_key) = 'text'
+ AND length(CAST(discovery_public_key AS BLOB)) = 64
+ THEN discovery_public_key
+ ELSE NULL
+ END AS discovery_public_key,
+ config_contract_version,
+ state_contract_version,
+ operator_contract_version,
+ status_contract_version
+FROM myc_state_metadata
+LIMIT 2"#;
+
+const INSERT_METADATA_SQL: &str = r#"INSERT INTO myc_state_metadata (
+ singleton,
+ normalized_config_sha256,
+ transport_public_key,
+ user_public_key,
+ discovery_public_key,
+ config_contract_version,
+ state_contract_version,
+ operator_contract_version,
+ status_contract_version
+) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)"#;
+
+/// Stable failure classes for typed Myc state-repository operations.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycStateRepositoryErrorKind {
+ Binding,
+ Transaction,
+ CommitOutcomeUnknown,
+}
+
+impl MycStateRepositoryErrorKind {
+ /// Returns the stable machine-readable failure code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::Binding => "state_repository_binding_invalid",
+ Self::Transaction => "state_repository_transaction_failed",
+ Self::CommitOutcomeUnknown => "state_repository_commit_outcome_unknown",
+ }
+ }
+}
+
+/// Source-free typed repository failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycStateRepositoryError {
+ kind: MycStateRepositoryErrorKind,
+}
+
+impl MycStateRepositoryError {
+ const fn new(kind: MycStateRepositoryErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable failure class.
+ #[must_use]
+ pub const fn kind(self) -> MycStateRepositoryErrorKind {
+ 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 MycStateRepositoryError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ MycStateRepositoryErrorKind::Binding => "Myc state repository binding is invalid",
+ MycStateRepositoryErrorKind::Transaction => "Myc state repository transaction failed",
+ MycStateRepositoryErrorKind::CommitOutcomeUnknown => {
+ "Myc state repository commit outcome is unknown"
+ }
+ })
+ }
+}
+
+impl fmt::Debug for MycStateRepositoryError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycStateRepositoryError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl Error for MycStateRepositoryError {}
+
+/// Borrowed typed access to one already-opened Myc state host.
+///
+/// Construction is sealed to [`crate::MycStateHost::repository`]:
+///
+/// ```compile_fail
+/// use myc::MycStateRepository;
+///
+/// let _ = MycStateRepository { host: todo!(), expected: todo!() };
+/// ```
+pub struct MycStateRepository<'host> {
+ host: &'host ServiceSqliteHost,
+ expected: &'host MycStateMetadata,
+}
+
+impl<'host> MycStateRepository<'host> {
+ pub(crate) const fn new(
+ host: &'host ServiceSqliteHost,
+ expected: &'host MycStateMetadata,
+ ) -> Self {
+ Self { host, expected }
+ }
+
+ /// Re-verifies the immutable Myc binding through the sealed transaction executor.
+ pub async fn verify_binding(&self) -> Result<(), MycStateRepositoryError> {
+ self.transact(false).await
+ }
+
+ pub(crate) async fn bind_or_verify(&self) -> Result<(), MycStateRepositoryError> {
+ self.transact(true).await
+ }
+
+ async fn transact(&self, initialize_missing: bool) -> Result<(), MycStateRepositoryError> {
+ let expected = PersistedMetadata::from(self.expected);
+ self.host
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let actual = read_metadata(transaction).await?;
+ match actual {
+ Some(actual) if actual == expected => Ok(()),
+ Some(_) => Err(RepositoryOperationError::Binding),
+ None if initialize_missing => {
+ insert_metadata(transaction, &expected).await?;
+ match read_metadata(transaction).await? {
+ Some(actual) if actual == expected => Ok(()),
+ Some(_) | None => Err(RepositoryOperationError::Binding),
+ }
+ }
+ None => Err(RepositoryOperationError::Binding),
+ }
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+ }
+}
+
+impl fmt::Debug for MycStateRepository<'_> {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycStateRepository")
+ .field("state", &"[sealed]")
+ .finish()
+ }
+}
+
+#[derive(Clone, PartialEq, Eq)]
+struct PersistedMetadata {
+ normalized_config_sha256: [u8; 32],
+ transport_public_key: Box<str>,
+ user_public_key: Box<str>,
+ discovery_public_key: Option<Box<str>>,
+ config_contract_version: u32,
+ state_contract_version: u32,
+ operator_contract_version: u32,
+ status_contract_version: u32,
+}
+
+impl From<&MycStateMetadata> for PersistedMetadata {
+ fn from(metadata: &MycStateMetadata) -> Self {
+ let identities = metadata.expected_identities();
+ let versions = metadata.policy_versions();
+ Self {
+ normalized_config_sha256: *metadata.configuration_digest().as_bytes(),
+ transport_public_key: identities.transport().as_hex().into(),
+ user_public_key: identities.user().as_hex().into(),
+ discovery_public_key: identities.discovery().map(|value| value.as_hex().into()),
+ config_contract_version: versions.configuration(),
+ state_contract_version: versions.state(),
+ operator_contract_version: versions.operator(),
+ status_contract_version: versions.status(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum RepositoryOperationError {
+ Binding,
+ Storage,
+}
+
+async fn read_metadata(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+) -> Result<Option<PersistedMetadata>, RepositoryOperationError> {
+ let rows = sqlx::query(READ_METADATA_SQL)
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| RepositoryOperationError::Storage)?;
+ if rows.len() > 1 {
+ return Err(RepositoryOperationError::Binding);
+ }
+ let Some(row) = rows.first() else {
+ return Ok(None);
+ };
+ let singleton = row
+ .try_get::<i64, _>("singleton")
+ .map_err(|_| RepositoryOperationError::Binding)?;
+ let normalized = row
+ .try_get::<Option<Vec<u8>>, _>("normalized_config_sha256")
+ .map_err(|_| RepositoryOperationError::Binding)?
+ .ok_or(RepositoryOperationError::Binding)?;
+ let normalized_config_sha256 = normalized
+ .try_into()
+ .map_err(|_| RepositoryOperationError::Binding)?;
+ let transport_public_key = bounded_public_key(row, "transport_public_key")?;
+ let user_public_key = bounded_public_key(row, "user_public_key")?;
+ let discovery_type = row
+ .try_get::<&str, _>("discovery_public_key_type")
+ .map_err(|_| RepositoryOperationError::Binding)?;
+ let discovery_public_key = match discovery_type {
+ "null" => None,
+ "text" => Some(bounded_public_key(row, "discovery_public_key")?),
+ _ => return Err(RepositoryOperationError::Binding),
+ };
+ let actual = PersistedMetadata {
+ normalized_config_sha256,
+ transport_public_key,
+ user_public_key,
+ discovery_public_key,
+ config_contract_version: bounded_version(row, "config_contract_version")?,
+ state_contract_version: bounded_version(row, "state_contract_version")?,
+ operator_contract_version: bounded_version(row, "operator_contract_version")?,
+ status_contract_version: bounded_version(row, "status_contract_version")?,
+ };
+ (singleton == 1)
+ .then_some(Some(actual))
+ .ok_or(RepositoryOperationError::Binding)
+}
+
+fn bounded_public_key(
+ row: &sqlx::sqlite::SqliteRow,
+ column: &str,
+) -> Result<Box<str>, RepositoryOperationError> {
+ let value = row
+ .try_get::<Option<String>, _>(column)
+ .map_err(|_| RepositoryOperationError::Binding)?
+ .ok_or(RepositoryOperationError::Binding)?;
+ let valid = value.len() == 64
+ && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
+ && !value.as_bytes().iter().any(u8::is_ascii_uppercase);
+ valid
+ .then(|| value.into_boxed_str())
+ .ok_or(RepositoryOperationError::Binding)
+}
+
+fn bounded_version(
+ row: &sqlx::sqlite::SqliteRow,
+ column: &str,
+) -> Result<u32, RepositoryOperationError> {
+ let value = row
+ .try_get::<i64, _>(column)
+ .map_err(|_| RepositoryOperationError::Binding)?;
+ u32::try_from(value)
+ .ok()
+ .filter(|value| *value != 0)
+ .ok_or(RepositoryOperationError::Binding)
+}
+
+async fn insert_metadata(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ expected: &PersistedMetadata,
+) -> Result<(), RepositoryOperationError> {
+ let result = sqlx::query(INSERT_METADATA_SQL)
+ .bind(expected.normalized_config_sha256.as_slice())
+ .bind(expected.transport_public_key.as_ref())
+ .bind(expected.user_public_key.as_ref())
+ .bind(expected.discovery_public_key.as_deref())
+ .bind(i64::from(expected.config_contract_version))
+ .bind(i64::from(expected.state_contract_version))
+ .bind(i64::from(expected.operator_contract_version))
+ .bind(i64::from(expected.status_contract_version))
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| RepositoryOperationError::Storage)?;
+ (result.rows_affected() == 1)
+ .then_some(())
+ .ok_or(RepositoryOperationError::Storage)
+}
+
+fn map_transaction_error(
+ error: ServiceSqliteTransactionError<RepositoryOperationError>,
+) -> MycStateRepositoryError {
+ if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown {
+ return MycStateRepositoryError::new(MycStateRepositoryErrorKind::CommitOutcomeUnknown);
+ }
+ let kind = match error.operation_error() {
+ Some(RepositoryOperationError::Binding) => MycStateRepositoryErrorKind::Binding,
+ Some(RepositoryOperationError::Storage) | None => MycStateRepositoryErrorKind::Transaction,
+ };
+ MycStateRepositoryError::new(kind)
+}
diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs
@@ -3,14 +3,16 @@
use std::error::Error;
use myc::{
- MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_SCHEMA_CATALOG_SHA256, MYC_STATE_SCHEMA_VERSION,
- MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_1_SHA256,
+ MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_CATALOG_SHA256,
+ MYC_STATE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT,
+ MYC_STATE_SCHEMA_VERSION_1_SHA256, MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256,
+ MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_2_SHA256,
MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog,
validate_myc_state_catalogs,
};
use radroots_service_sqlite::{
- MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaObject,
- SchemaObjectKind, SchemaVersionCatalog,
+ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest,
+ SchemaObject, SchemaObjectKind, SchemaVersionCatalog,
};
const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs");
@@ -18,45 +20,69 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs");
const MANIFEST: &str = include_str!("../Cargo.toml");
#[test]
-fn schema_v1_and_empty_migration_catalog_have_exact_literal_identities() {
+fn schema_v1_v2_and_single_migration_have_exact_literal_identities() {
let migrations = myc_migration_catalog().expect("Myc migration catalog");
let schema = myc_schema_catalog().expect("Myc schema catalog");
- assert_eq!(MYC_STATE_SCHEMA_VERSION, 1);
- assert!(migrations.descriptors().is_empty());
- assert_eq!(migrations.current_version(), 1);
+ assert_eq!(MYC_STATE_BASE_SCHEMA_VERSION, 1);
+ assert_eq!(MYC_STATE_SCHEMA_VERSION, 2);
+ assert_eq!(migrations.descriptors().len(), 1);
+ let migration = &migrations.descriptors()[0];
+ assert_eq!(migration.target_version(), 2);
+ assert_eq!(migration.name().as_str(), "create_myc_state_metadata");
+ assert_eq!(
+ migration.checksum().as_bytes(),
+ &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256
+ );
+ assert_eq!(migrations.current_version(), 2);
assert_eq!(
migrations.digest().as_bytes(),
&MYC_MIGRATION_CATALOG_SHA256
);
- assert_eq!(schema.versions().len(), 1);
- let version = schema.versions()[0];
- assert_eq!(version.version(), 1);
+ assert_eq!(schema.versions().len(), 2);
+ assert_eq!(schema.versions()[0].version(), 1);
assert_eq!(
- version.object_count(),
+ schema.versions()[0].object_count(),
MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT
);
- assert_eq!(version.object_count(), 6);
assert_eq!(
- version.digest().as_bytes(),
+ schema.versions()[0].digest().as_bytes(),
&MYC_STATE_SCHEMA_VERSION_1_SHA256
);
+ assert_eq!(schema.versions()[1].version(), 2);
+ assert_eq!(
+ schema.versions()[1].object_count(),
+ MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT
+ );
+ assert_eq!(schema.versions()[1].object_count(), 9);
+ assert_eq!(
+ schema.versions()[1].digest().as_bytes(),
+ &MYC_STATE_SCHEMA_VERSION_2_SHA256
+ );
assert_eq!(schema.digest().as_bytes(), &MYC_STATE_SCHEMA_CATALOG_SHA256);
assert_eq!(schema.migration_catalog_digest(), migrations.digest());
validate_myc_state_catalogs(&migrations, &schema).expect("exact catalogs");
assert_eq!(
+ hex::encode(MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256),
+ "c5eb978bda1bc70c70bd9bd44d8cba70cb2857d26a9dce74961f4b781e710037"
+ );
+ assert_eq!(
hex::encode(MYC_MIGRATION_CATALOG_SHA256),
- "ec89dc8f7b6c2a11b967e33808e4031e29b3970ffee4959bff9bad352877ee9b"
+ "be15584e4e6fe1f5b80209e8f6125ecc9281fc22e9769a79f210c30c431ff462"
);
assert_eq!(
hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256),
"94dc66fbca601679615c055229dc0db6119f5bd92b04390c67f698a036fa78ae"
);
assert_eq!(
+ hex::encode(MYC_STATE_SCHEMA_VERSION_2_SHA256),
+ "94739b5a34ca8ed130b946d092731b59bf1548fe541d9a9269a33d0aed1ea09e"
+ );
+ assert_eq!(
hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256),
- "2309153f3b49754887c548a7459b3e09099c60f7146b373c8f96706c6768d791"
+ "673f8ba2095ee02e8048af850d294436cb7b8150e91693b7727fae05812e831c"
);
}
@@ -75,7 +101,13 @@ fn independent_validator_rejects_migration_or_schema_drift() {
MycStateCatalogErrorKind::CatalogMismatch
);
- let empty_migrations = myc_migration_catalog().expect("empty migrations");
+ let expected_migrations = myc_migration_catalog().expect("expected migrations");
+ let v1 = SchemaVersionCatalog::new(
+ 1,
+ [],
+ SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_1_SHA256),
+ )
+ .expect("schema v1");
let object_digest =
SchemaObject::computed_digest(SchemaObjectKind::Table, "unexpected", "unexpected", SQL)
.expect("object digest");
@@ -88,11 +120,11 @@ fn independent_validator_rejects_migration_or_schema_drift() {
)
.expect("schema object");
let snapshot_digest =
- SchemaVersionCatalog::computed_digest(1, [object.clone()]).expect("snapshot digest");
- let version = SchemaVersionCatalog::new(1, [object], snapshot_digest).expect("version");
- let schema = SchemaCatalog::new(&empty_migrations, [version]).expect("drift schema catalog");
+ SchemaVersionCatalog::computed_digest(2, [object.clone()]).expect("snapshot digest");
+ let v2 = SchemaVersionCatalog::new(2, [object], snapshot_digest).expect("schema v2");
+ let schema = SchemaCatalog::new(&expected_migrations, [v1, v2]).expect("drift schema catalog");
assert_eq!(
- validate_myc_state_catalogs(&empty_migrations, &schema)
+ validate_myc_state_catalogs(&expected_migrations, &schema)
.expect_err("schema drift")
.kind(),
MycStateCatalogErrorKind::CatalogMismatch
@@ -101,34 +133,20 @@ fn independent_validator_rejects_migration_or_schema_drift() {
#[test]
fn catalog_errors_are_stable_source_free_and_redacted() {
- let migrations = myc_migration_catalog().expect("migration catalog");
- let object_digest = SchemaObject::computed_digest(
- SchemaObjectKind::Table,
- "secret_table",
- "secret_table",
- "secret SQL text",
- )
- .expect("object digest");
- let object = SchemaObject::new(
- SchemaObjectKind::Table,
- "secret_table",
- "secret_table",
- "secret SQL text",
- object_digest,
- )
- .expect("object");
- let snapshot =
- SchemaVersionCatalog::computed_digest(1, [object.clone()]).expect("snapshot digest");
- let version = SchemaVersionCatalog::new(1, [object], snapshot).expect("version");
- let schema = SchemaCatalog::new(&migrations, [version]).expect("schema catalog");
- let error = validate_myc_state_catalogs(&migrations, &schema).expect_err("mismatch");
+ const SQL: &str = "CREATE TABLE secret_table (value INTEGER NOT NULL) STRICT";
+ let migration =
+ MigrationDescriptor::sql(2, "secret_schema", SQL, MigrationChecksum::for_sql(SQL))
+ .expect("migration");
+ let migrations = MigrationCatalog::new([migration]).expect("migration catalog");
+ let expected_schema = myc_schema_catalog().expect("expected schema");
+ let error = validate_myc_state_catalogs(&migrations, &expected_schema).expect_err("mismatch");
assert_eq!(error.kind(), MycStateCatalogErrorKind::CatalogMismatch);
assert_eq!(error.code(), "state_catalog_mismatch");
assert!(Error::source(&error).is_none());
let rendered = format!("{error} {error:?}");
assert!(!rendered.contains("secret"));
- assert!(!rendered.contains(&hex::encode(snapshot.as_bytes())));
+ assert!(!rendered.contains(&hex::encode(MigrationChecksum::for_sql(SQL).as_bytes())));
}
#[test]
@@ -138,20 +156,19 @@ fn catalog_source_is_pure_pinned_and_uses_only_the_shared_authority() {
));
assert!(LIB_SOURCE.contains("mod state_catalog;"));
assert!(!LIB_SOURCE.contains("pub mod state_catalog;"));
- assert!(CATALOG_SOURCE.contains("MigrationCatalog::new([])"));
+ assert!(CATALOG_SOURCE.contains("MigrationDescriptor::sql("));
+ assert!(CATALOG_SOURCE.contains("MigrationChecksum::from_bytes("));
assert!(CATALOG_SOURCE.contains("SchemaDigest::from_bytes("));
assert!(!CATALOG_SOURCE.contains("computed_digest"));
for forbidden in [
"sqlx::",
"rusqlite",
"libsqlite3_sys",
- "CREATE TABLE",
"raw_sql",
"std::fs",
"std::path",
- "Connection",
+ "SqliteConnection",
"Transaction",
- "MigrationDescriptor",
] {
assert!(
!CATALOG_SOURCE.contains(forbidden),
diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs
@@ -65,7 +65,7 @@ fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentit
"test-target",
"service-host",
1,
- 1,
+ myc::MYC_STATE_SCHEMA_VERSION,
1,
1,
1,
@@ -74,6 +74,23 @@ fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentit
(applied_at, build)
}
+fn mismatched_migration_build() -> MigrationBuildIdentity {
+ MigrationBuildIdentity::new(
+ env!("CARGO_PKG_VERSION"),
+ "1111111111111111111111111111111111111111",
+ "b44119fbac5985be8127ad1bf56d2950e6399427",
+ "rustc-test",
+ "test-target",
+ "service-host",
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("structurally valid mismatched build")
+}
+
#[tokio::test]
async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly() {
let directory = tempfile::tempdir().expect("temporary root");
@@ -84,7 +101,8 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly(
let lock = runtime.artifacts().state_lock();
assert!(!state.exists());
- initialize_myc_state(&runtime, &metadata)
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&runtime, &metadata, applied_at, &build)
.await
.expect("create-new initialization");
assert!(state.is_file());
@@ -98,12 +116,11 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly(
0o600
);
- let duplicate = initialize_myc_state(&runtime, &metadata)
+ let duplicate = initialize_myc_state(&runtime, &metadata, applied_at, &build)
.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, &metadata, applied_at, &build)
.await
.expect("existing writable state");
@@ -147,7 +164,18 @@ async fn missing_state_and_mismatched_evidence_fail_before_database_creation() {
assert_eq!(missing.kind(), MycStateHostErrorKind::ReadWriteOpen);
assert!(!primary.artifacts().state_database().exists());
- let mismatch = initialize_myc_state(&secondary, &primary_metadata)
+ let invalid_build = initialize_myc_state(
+ &primary,
+ &primary_metadata,
+ applied_at,
+ &mismatched_migration_build(),
+ )
+ .await
+ .expect_err("migration build contract mismatch");
+ assert_eq!(invalid_build.kind(), MycStateHostErrorKind::InvalidEvidence);
+ assert!(!primary.artifacts().state_database().exists());
+
+ let mismatch = initialize_myc_state(&secondary, &primary_metadata, applied_at, &build)
.await
.expect_err("cross-instance metadata");
assert_eq!(mismatch.kind(), MycStateHostErrorKind::InvalidEvidence);
diff --git a/tests/services_hardening_state_metadata.rs b/tests/services_hardening_state_metadata.rs
@@ -4,9 +4,10 @@ use std::{error::Error, path::Path};
use myc::{
MYC_CONFIG_SCHEMA_VERSION, MYC_OPERATOR_CONTRACT_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION,
- MYC_STATE_APPLICATION_ID, MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycStateMetadata,
- MycStateMetadataErrorKind, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform,
- parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context,
+ MYC_STATE_APPLICATION_ID, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION,
+ MycConfigProfile, MycStateMetadata, MycStateMetadataErrorKind, RadrootsHostEnvironment,
+ RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from, parse_myc_config_v1,
+ resolve_myc_runtime_context,
};
use radroots_storage::event::SourceGeneration;
@@ -53,7 +54,7 @@ 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();
+ let database = metadata.initial_database_metadata();
assert_eq!(MYC_STATE_APPLICATION_ID.to_be_bytes(), *b"RDMY");
assert_eq!(database.application_id().get(), MYC_STATE_APPLICATION_ID);
@@ -62,9 +63,16 @@ fn exact_database_configuration_identity_and_policy_bindings_are_frozen() {
assert_eq!(database.source_generation().as_bytes(), &[0x5a; 32]);
assert_eq!(
database.state_schema_version().get(),
- MYC_STATE_SCHEMA_VERSION
+ MYC_STATE_BASE_SCHEMA_VERSION
);
assert_eq!(database.created_at_unix_ms(), 1_725_000_000_000);
+ assert_eq!(
+ metadata
+ .database_identity()
+ .supported_state_schema_version()
+ .get(),
+ MYC_STATE_SCHEMA_VERSION
+ );
let identities = metadata.expected_identities();
assert_eq!(identities.transport().as_hex(), "4".repeat(64));
diff --git a/tests/services_hardening_state_repository.rs b/tests/services_hardening_state_repository.rs
@@ -0,0 +1,283 @@
+#![forbid(unsafe_code)]
+
+use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path};
+
+use myc::{
+ MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycStateHostErrorKind, MycStateMetadata,
+ MycStateRepositoryErrorKind, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform,
+ initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write,
+ parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context,
+};
+use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
+use radroots_storage::event::SourceGeneration;
+use sqlx::{ConnectOptions, Connection, Row, sqlite::SqliteConnectOptions};
+
+const CONFIG_EXAMPLE: &[u8] =
+ include_bytes!("../contracts/services_hardening/config.v1.example.toml");
+const LIB_SOURCE: &str = include_str!("../src/lib.rs");
+const HOST_SOURCE: &str = include_str!("../src/state_host.rs");
+const REPOSITORY_SOURCE: &str = include_str!("../src/state_repository.rs");
+
+fn runtime(root: &Path) -> myc::MycRuntimeContext {
+ let root = root.to_str().expect("UTF-8 temporary root");
+ let invocation = parse_myc_cli_v1_from([
+ "myc",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "primary",
+ "--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, source: &[u8]) -> MycStateMetadata {
+ let configuration =
+ parse_myc_config_v1(source, MycConfigProfile::RepoLocal).expect("configuration");
+ MycStateMetadata::new(
+ runtime,
+ &configuration,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .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,
+ MYC_STATE_SCHEMA_VERSION,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("build identity");
+ (applied_at, build)
+}
+
+#[tokio::test]
+async fn initialization_migrates_and_binds_exact_metadata_before_inspection() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path());
+ prepare_state_directory(&runtime);
+ let metadata = metadata(&runtime, CONFIG_EXAMPLE);
+ let (applied_at, build) = migration_evidence();
+
+ initialize_myc_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialized current state");
+ let inspection = open_myc_state_inspection(&runtime, &metadata)
+ .await
+ .expect("current inspection");
+ inspection
+ .repository()
+ .verify_binding()
+ .await
+ .expect("typed repository verification");
+ assert_eq!(
+ format!("{:?}", inspection.repository()),
+ "MycStateRepository { state: \"[sealed]\" }"
+ );
+ inspection.close().await.expect("inspection close");
+
+ let options = SqliteConnectOptions::new()
+ .filename(runtime.artifacts().state_database())
+ .create_if_missing(false)
+ .read_only(true)
+ .disable_statement_logging();
+ let mut connection = sqlx::SqliteConnection::connect_with(&options)
+ .await
+ .expect("test inspection connection");
+ let row = sqlx::query(
+ "SELECT state_schema_version FROM radroots_service_metadata WHERE singleton = 1",
+ )
+ .fetch_one(&mut connection)
+ .await
+ .expect("shared metadata row");
+ assert_eq!(row.get::<i64, _>(0), i64::from(MYC_STATE_SCHEMA_VERSION));
+ let migration = sqlx::query("SELECT version, name FROM schema_migrations LIMIT 2")
+ .fetch_one(&mut connection)
+ .await
+ .expect("migration row");
+ assert_eq!(migration.get::<i64, _>(0), 2);
+ assert_eq!(migration.get::<String, _>(1), "create_myc_state_metadata");
+ let binding = sqlx::query(
+ "SELECT normalized_config_sha256, transport_public_key, user_public_key, \
+ discovery_public_key, config_contract_version, state_contract_version, \
+ operator_contract_version, status_contract_version \
+ FROM myc_state_metadata WHERE singleton = 1",
+ )
+ .fetch_one(&mut connection)
+ .await
+ .expect("Myc metadata row");
+ assert_eq!(
+ binding.get::<Vec<u8>, _>(0),
+ metadata.configuration_digest().as_bytes()
+ );
+ assert_eq!(
+ binding.get::<String, _>(1),
+ metadata.expected_identities().transport().as_hex()
+ );
+ assert_eq!(
+ binding.get::<String, _>(2),
+ metadata.expected_identities().user().as_hex()
+ );
+ assert_eq!(
+ binding.get::<String, _>(3),
+ metadata
+ .expected_identities()
+ .discovery()
+ .expect("discovery identity")
+ .as_hex()
+ );
+ assert_eq!(binding.get::<i64, _>(4), 1);
+ assert_eq!(binding.get::<i64, _>(5), 2);
+ assert_eq!(binding.get::<i64, _>(6), 1);
+ assert_eq!(binding.get::<i64, _>(7), 1);
+ connection.close().await.expect("test connection close");
+}
+
+#[tokio::test]
+async fn exact_binding_is_idempotent_and_conflicting_configuration_fails_closed() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path());
+ prepare_state_directory(&runtime);
+ let expected = metadata(&runtime, CONFIG_EXAMPLE);
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&runtime, &expected, applied_at, &build)
+ .await
+ .expect("initialization");
+
+ let writer = open_myc_state_read_write(&runtime, &expected, applied_at, &build)
+ .await
+ .expect("idempotent exact open");
+ writer
+ .repository()
+ .verify_binding()
+ .await
+ .expect("exact binding");
+ writer.close().await.expect("writer close");
+
+ let changed_source = String::from_utf8(CONFIG_EXAMPLE.to_vec())
+ .expect("UTF-8 config")
+ .replace("level = \"info\"", "level = \"warn\"");
+ let changed = metadata(&runtime, changed_source.as_bytes());
+ let error = open_myc_state_read_write(&runtime, &changed, applied_at, &build)
+ .await
+ .expect_err("changed normalized binding");
+ assert_eq!(error.kind(), MycStateHostErrorKind::Repository);
+
+ let inspection = open_myc_state_inspection(&runtime, &expected)
+ .await
+ .expect("original binding remains authoritative");
+ inspection.close().await.expect("inspection close");
+}
+
+#[tokio::test]
+async fn database_guards_reject_metadata_update_and_delete() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path());
+ prepare_state_directory(&runtime);
+ let metadata = metadata(&runtime, CONFIG_EXAMPLE);
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialization");
+
+ let options = SqliteConnectOptions::new()
+ .filename(runtime.artifacts().state_database())
+ .create_if_missing(false)
+ .disable_statement_logging();
+ let mut connection = sqlx::SqliteConnection::connect_with(&options)
+ .await
+ .expect("test connection");
+ assert!(
+ sqlx::query("UPDATE myc_state_metadata SET status_contract_version = 2")
+ .execute(&mut connection)
+ .await
+ .is_err()
+ );
+ assert!(
+ sqlx::query("DELETE FROM myc_state_metadata")
+ .execute(&mut connection)
+ .await
+ .is_err()
+ );
+ connection.close().await.expect("test connection close");
+}
+
+#[tokio::test]
+async fn repository_boundary_is_sealed_typed_redacted_and_network_free() {
+ assert!(LIB_SOURCE.contains("mod state_repository;"));
+ assert!(!LIB_SOURCE.contains("pub mod state_repository;"));
+ assert!(HOST_SOURCE.contains("pub const fn repository(&self) -> MycStateRepository<'_>"));
+ assert!(REPOSITORY_SOURCE.contains(".transaction(move |transaction|"));
+ assert!(REPOSITORY_SOURCE.contains("INSERT INTO myc_state_metadata"));
+ for forbidden in [
+ "SqliteConnection",
+ "SqlitePool",
+ "PoolConnection",
+ "BEGIN ",
+ "COMMIT",
+ "ROLLBACK",
+ "provider",
+ "relay",
+ "reqwest",
+ "nostr::",
+ "std::fs",
+ "std::path",
+ "std::env",
+ "std::time",
+ ] {
+ assert!(
+ !REPOSITORY_SOURCE.contains(forbidden),
+ "found forbidden repository authority `{forbidden}`"
+ );
+ }
+
+ let kind = myc::MycStateRepositoryErrorKind::Binding;
+ assert_eq!(kind.code(), "state_repository_binding_invalid");
+ let rendered = format!("{kind:?}");
+ assert!(!rendered.contains("state.sqlite"));
+
+ let directory = tempfile::tempdir().expect("temporary root");
+ let context = runtime(directory.path());
+ prepare_state_directory(&context);
+ let metadata = metadata(&context, CONFIG_EXAMPLE);
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&context, &metadata, applied_at, &build)
+ .await
+ .expect("initialization");
+ let inspection = open_myc_state_inspection(&context, &metadata)
+ .await
+ .expect("inspection");
+ inspection.close().await.expect("inspection close");
+ let error = inspection
+ .repository()
+ .verify_binding()
+ .await
+ .expect_err("closed host rejects repository admission");
+ assert_eq!(error.kind(), MycStateRepositoryErrorKind::Transaction);
+ assert!(Error::source(&error).is_none());
+ assert!(!format!("{error} {error:?}").contains("secret"));
+}