myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

state_host.rs (18945B)


      1 //! Sealed lifecycle boundary for the canonical Myc SQLite state catalog.
      2 
      3 use core::fmt;
      4 use std::{error::Error, path::Path};
      5 
      6 use radroots_service_sqlite::{
      7     BackupCreatedAtUnixMs, ExistingServiceDatabaseIntent, IntegrityCheckedAtUnixMs,
      8     MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
      9     ServiceBackupManifest, ServiceSqliteApplicationId, ServiceSqliteConnectionOptions,
     10     ServiceSqliteHost, ServiceSqliteInitializer, ServiceSqliteInitializerFuture,
     11     ServiceSqliteIntegrityReport, ServiceSqlitePaths, initialize_database,
     12 };
     13 
     14 use crate::{
     15     MYC_STATE_APPLICATION_ID, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION,
     16     MycConfigDocumentV1, MycRuntimeContext, MycStateMaintenanceError, MycStateMaintenanceErrorKind,
     17     MycStateMetadata, MycStateRepository, myc_migration_catalog, myc_schema_catalog,
     18     validate_myc_state_catalogs,
     19 };
     20 
     21 /// Stable lifecycle mode of one opened Myc state host.
     22 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     23 pub enum MycStateHostMode {
     24     ReadWriteExisting,
     25     ReadOnlyInspection,
     26 }
     27 
     28 /// Stable source-free class for a Myc state-host lifecycle failure.
     29 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     30 pub enum MycStateHostErrorKind {
     31     InvalidPaths,
     32     InvalidEvidence,
     33     Catalog,
     34     Initialize,
     35     ReadWriteOpen,
     36     InspectionOpen,
     37     Repository,
     38     Close,
     39 }
     40 
     41 impl MycStateHostErrorKind {
     42     /// Returns the stable machine-readable failure code.
     43     #[must_use]
     44     pub const fn code(self) -> &'static str {
     45         match self {
     46             Self::InvalidPaths => "state_paths_invalid",
     47             Self::InvalidEvidence => "state_evidence_invalid",
     48             Self::Catalog => "state_catalog_invalid",
     49             Self::Initialize => "state_initialize_failed",
     50             Self::ReadWriteOpen => "state_read_write_open_failed",
     51             Self::InspectionOpen => "state_inspection_open_failed",
     52             Self::Repository => "state_repository_failed",
     53             Self::Close => "state_close_failed",
     54         }
     55     }
     56 }
     57 
     58 /// Redacted Myc state-host lifecycle failure.
     59 #[derive(Clone, Copy, PartialEq, Eq)]
     60 pub struct MycStateHostError {
     61     kind: MycStateHostErrorKind,
     62 }
     63 
     64 impl MycStateHostError {
     65     const fn new(kind: MycStateHostErrorKind) -> Self {
     66         Self { kind }
     67     }
     68 
     69     /// Returns the stable failure class.
     70     #[must_use]
     71     pub const fn kind(self) -> MycStateHostErrorKind {
     72         self.kind
     73     }
     74 
     75     /// Returns the stable machine-readable failure code.
     76     #[must_use]
     77     pub const fn code(self) -> &'static str {
     78         self.kind.code()
     79     }
     80 }
     81 
     82 impl fmt::Display for MycStateHostError {
     83     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     84         formatter.write_str(match self.kind {
     85             MycStateHostErrorKind::InvalidPaths => "Myc state paths are invalid",
     86             MycStateHostErrorKind::InvalidEvidence => "Myc state identity evidence is invalid",
     87             MycStateHostErrorKind::Catalog => "Myc state catalogs are invalid",
     88             MycStateHostErrorKind::Initialize => "Myc state initialization failed",
     89             MycStateHostErrorKind::ReadWriteOpen => "Myc writable state could not be opened",
     90             MycStateHostErrorKind::InspectionOpen => "Myc inspection state could not be opened",
     91             MycStateHostErrorKind::Repository => "Myc state repository binding failed",
     92             MycStateHostErrorKind::Close => "Myc state host could not be closed",
     93         })
     94     }
     95 }
     96 
     97 impl fmt::Debug for MycStateHostError {
     98     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     99         formatter
    100             .debug_struct("MycStateHostError")
    101             .field("kind", &self.kind)
    102             .finish()
    103     }
    104 }
    105 
    106 impl Error for MycStateHostError {}
    107 
    108 /// One opened Myc state catalog whose raw SQLite authority remains sealed.
    109 ///
    110 /// Callers cannot construct the wrapper or extract the shared host:
    111 ///
    112 /// ```compile_fail
    113 /// use myc::{MycStateHost, MycStateHostMode};
    114 ///
    115 /// let _ = MycStateHost {
    116 ///     host: todo!(),
    117 ///     mode: MycStateHostMode::ReadWriteExisting,
    118 /// };
    119 /// ```
    120 ///
    121 /// The wrapper intentionally exposes no transaction or connection escape:
    122 ///
    123 /// ```compile_fail
    124 /// use myc::MycStateHost;
    125 ///
    126 /// fn bypass(host: &MycStateHost) {
    127 ///     let _ = host.transaction(|_| async { Ok::<_, ()>(()) });
    128 /// }
    129 /// ```
    130 pub struct MycStateHost {
    131     host: ServiceSqliteHost,
    132     mode: MycStateHostMode,
    133     metadata: MycStateMetadata,
    134 }
    135 
    136 impl MycStateHost {
    137     /// Returns the lifecycle mode selected when this host was opened.
    138     #[must_use]
    139     pub const fn mode(&self) -> MycStateHostMode {
    140         self.mode
    141     }
    142 
    143     /// Returns the immutable Myc metadata bound to this host session.
    144     #[must_use]
    145     pub const fn metadata(&self) -> &MycStateMetadata {
    146         &self.metadata
    147     }
    148 
    149     /// Returns sealed typed repository access bound to this host and metadata.
    150     #[must_use]
    151     pub const fn repository(&self) -> MycStateRepository<'_> {
    152         MycStateRepository::new(
    153             &self.host,
    154             &self.metadata,
    155             matches!(self.mode, MycStateHostMode::ReadWriteExisting),
    156         )
    157     }
    158 
    159     /// Captures one governed point-in-time backup from a writable Myc host.
    160     ///
    161     /// The staging directory must be a new absolute path. The returned
    162     /// manifest remains in memory and contains no protected identity material.
    163     pub async fn capture_online_backup(
    164         &self,
    165         staging_directory: &Path,
    166         created_at: BackupCreatedAtUnixMs,
    167     ) -> Result<ServiceBackupManifest, MycStateMaintenanceError> {
    168         if self.mode != MycStateHostMode::ReadWriteExisting {
    169             return Err(MycStateMaintenanceError::new(
    170                 MycStateMaintenanceErrorKind::InvalidMode,
    171             ));
    172         }
    173         self.host
    174             .capture_online_backup(staging_directory, created_at)
    175             .await
    176             .map_err(MycStateMaintenanceError::from_sqlite)
    177     }
    178 
    179     /// Runs one explicit bounded integrity inspection over this host.
    180     pub async fn inspect_integrity(
    181         &self,
    182         checked_at: IntegrityCheckedAtUnixMs,
    183     ) -> Result<ServiceSqliteIntegrityReport, MycStateMaintenanceError> {
    184         self.host
    185             .inspect_integrity(checked_at)
    186             .await
    187             .map_err(MycStateMaintenanceError::from_sqlite)
    188     }
    189 
    190     /// Drains the shared host and explicitly releases retained authority.
    191     pub async fn close(&self) -> Result<(), MycStateHostError> {
    192         self.host
    193             .close()
    194             .await
    195             .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Close))
    196     }
    197 }
    198 
    199 impl fmt::Debug for MycStateHost {
    200     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    201         formatter
    202             .debug_struct("MycStateHost")
    203             .field("mode", &self.mode)
    204             .field("state", &"[sealed]")
    205             .finish()
    206     }
    207 }
    208 
    209 /// Creates a missing Myc catalog exactly once and releases initialization authority.
    210 ///
    211 /// This function never opens an existing database as initialization. The caller
    212 /// injects the shared metadata evidence; the Myc metadata-binding layer owns
    213 /// its exact application and configuration bindings.
    214 pub async fn initialize_myc_state(
    215     runtime: &MycRuntimeContext,
    216     metadata: &MycStateMetadata,
    217     applied_at: MigrationAppliedAtUnixSeconds,
    218     build: &MigrationBuildIdentity,
    219 ) -> Result<(), MycStateHostError> {
    220     let paths = state_paths(runtime)?;
    221     require_metadata(runtime, metadata)?;
    222     require_migration_build(metadata, build)?;
    223     let (migrations, schema) = catalogs()?;
    224     provision_state_directory(runtime)?;
    225     let authority = initialize_database(
    226         &paths,
    227         OpenMode::Initialize,
    228         metadata.initial_database_metadata(),
    229         &schema,
    230         initialize_empty_catalog,
    231     )
    232     .await
    233     .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?;
    234     let identity = metadata.database_identity();
    235     let (host, outcome) = ServiceSqliteHost::open_initialized(
    236         &paths,
    237         &identity,
    238         &migrations,
    239         &schema,
    240         ServiceSqliteConnectionOptions::reviewed(),
    241         authority,
    242         applied_at,
    243         build,
    244         &[],
    245     )
    246     .await
    247     .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?;
    248     let state = MycStateHost {
    249         host,
    250         mode: MycStateHostMode::ReadWriteExisting,
    251         metadata: metadata.clone(),
    252     };
    253     if !exact_initialization_outcome(outcome) {
    254         return Err(close_error(&state.host, MycStateHostErrorKind::Catalog).await);
    255     }
    256     if state.repository().bind_or_verify().await.is_err() {
    257         return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
    258     }
    259     state
    260         .close()
    261         .await
    262         .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))
    263 }
    264 
    265 /// Opens an already initialized Myc catalog with exclusive writer authority.
    266 ///
    267 /// Missing state is never created. Migration time and build identity remain
    268 /// explicit injected evidence even while the baseline migration catalog is
    269 /// empty.
    270 pub async fn open_myc_state_read_write(
    271     runtime: &MycRuntimeContext,
    272     metadata: &MycStateMetadata,
    273     applied_at: MigrationAppliedAtUnixSeconds,
    274     build: &MigrationBuildIdentity,
    275 ) -> Result<MycStateHost, MycStateHostError> {
    276     let paths = state_paths(runtime)?;
    277     require_metadata(runtime, metadata)?;
    278     require_migration_build(metadata, build)?;
    279     let identity = metadata.database_identity();
    280     let (migrations, schema) = catalogs()?;
    281     let (host, outcome) = ServiceSqliteHost::open_read_write_existing(
    282         &paths,
    283         &identity,
    284         &migrations,
    285         &schema,
    286         ServiceSqliteConnectionOptions::reviewed(),
    287         applied_at,
    288         build,
    289         &[],
    290     )
    291     .await
    292     .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?;
    293     if !exact_existing_outcome(outcome) {
    294         return Err(close_error(&host, MycStateHostErrorKind::Catalog).await);
    295     }
    296     let state = MycStateHost {
    297         host,
    298         mode: MycStateHostMode::ReadWriteExisting,
    299         metadata: metadata.clone(),
    300     };
    301     if state.repository().bind_or_verify().await.is_err() {
    302         return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
    303     }
    304     Ok(state)
    305 }
    306 
    307 /// Opens existing state from a sealed intent and discovers actual source metadata.
    308 ///
    309 /// The caller supplies configuration policy but no source generation or
    310 /// creation-time guess. Those values are discovered from the same retained
    311 /// authority that is returned in the host.
    312 pub async fn open_myc_state_read_write_from_config(
    313     runtime: &MycRuntimeContext,
    314     configuration: &MycConfigDocumentV1,
    315     applied_at: MigrationAppliedAtUnixSeconds,
    316     build: &MigrationBuildIdentity,
    317 ) -> Result<MycStateHost, MycStateHostError> {
    318     let paths = state_paths(runtime)?;
    319     let (migrations, schema) = catalogs()?;
    320     let intent = existing_intent(&paths)?;
    321     let (opened, outcome) = ServiceSqliteHost::open_read_write_existing_with_intent(
    322         &paths,
    323         &intent,
    324         &migrations,
    325         &schema,
    326         ServiceSqliteConnectionOptions::reviewed(),
    327         applied_at,
    328         build,
    329         &[],
    330     )
    331     .await
    332     .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?;
    333     if !exact_existing_outcome(outcome) {
    334         let (host, _) = opened.into_parts();
    335         return Err(close_error(&host, MycStateHostErrorKind::Catalog).await);
    336     }
    337     let (host, actual) = opened.into_parts();
    338     let metadata = match MycStateMetadata::from_existing_database(runtime, configuration, &actual) {
    339         Ok(metadata) => metadata,
    340         Err(_) => {
    341             return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await);
    342         }
    343     };
    344     if require_migration_build(&metadata, build).is_err() {
    345         return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await);
    346     }
    347     let state = MycStateHost {
    348         host,
    349         mode: MycStateHostMode::ReadWriteExisting,
    350         metadata,
    351     };
    352     if state.repository().bind_or_verify().await.is_err() {
    353         return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
    354     }
    355     Ok(state)
    356 }
    357 
    358 /// Opens an already initialized Myc catalog for immutable inspection.
    359 pub async fn open_myc_state_inspection(
    360     runtime: &MycRuntimeContext,
    361     metadata: &MycStateMetadata,
    362 ) -> Result<MycStateHost, MycStateHostError> {
    363     let paths = state_paths(runtime)?;
    364     require_metadata(runtime, metadata)?;
    365     let identity = metadata.database_identity();
    366     let (migrations, schema) = catalogs()?;
    367     let host = ServiceSqliteHost::open_read_only_inspection(
    368         &paths,
    369         &identity,
    370         &migrations,
    371         &schema,
    372         ServiceSqliteConnectionOptions::reviewed(),
    373     )
    374     .await
    375     .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?;
    376     let state = MycStateHost {
    377         host,
    378         mode: MycStateHostMode::ReadOnlyInspection,
    379         metadata: metadata.clone(),
    380     };
    381     if state.repository().verify_binding().await.is_err() {
    382         return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
    383     }
    384     Ok(state)
    385 }
    386 
    387 /// Opens existing inspection state from a sealed intent and actual metadata.
    388 pub async fn open_myc_state_inspection_from_config(
    389     runtime: &MycRuntimeContext,
    390     configuration: &MycConfigDocumentV1,
    391 ) -> Result<MycStateHost, MycStateHostError> {
    392     let paths = state_paths(runtime)?;
    393     let (migrations, schema) = catalogs()?;
    394     let intent = existing_intent(&paths)?;
    395     let opened = ServiceSqliteHost::open_read_only_inspection_with_intent(
    396         &paths,
    397         &intent,
    398         &migrations,
    399         &schema,
    400         ServiceSqliteConnectionOptions::reviewed(),
    401     )
    402     .await
    403     .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?;
    404     let (host, actual) = opened.into_parts();
    405     let metadata = match MycStateMetadata::from_existing_database(runtime, configuration, &actual) {
    406         Ok(metadata) => metadata,
    407         Err(_) => {
    408             return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await);
    409         }
    410     };
    411     let state = MycStateHost {
    412         host,
    413         mode: MycStateHostMode::ReadOnlyInspection,
    414         metadata,
    415     };
    416     if state.repository().verify_binding().await.is_err() {
    417         return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
    418     }
    419     Ok(state)
    420 }
    421 
    422 async fn close_error(
    423     host: &ServiceSqliteHost,
    424     fallback: MycStateHostErrorKind,
    425 ) -> MycStateHostError {
    426     if host.close().await.is_err() {
    427         MycStateHostError::new(MycStateHostErrorKind::Close)
    428     } else {
    429         MycStateHostError::new(fallback)
    430     }
    431 }
    432 
    433 fn existing_intent(
    434     paths: &ServiceSqlitePaths,
    435 ) -> Result<ExistingServiceDatabaseIntent, MycStateHostError> {
    436     let schema = core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION)
    437         .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))?;
    438     let application = ServiceSqliteApplicationId::new(MYC_STATE_APPLICATION_ID)
    439         .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))?;
    440     Ok(ExistingServiceDatabaseIntent::new(
    441         paths,
    442         schema,
    443         application,
    444     ))
    445 }
    446 
    447 pub(crate) fn state_paths(
    448     runtime: &MycRuntimeContext,
    449 ) -> Result<ServiceSqlitePaths, MycStateHostError> {
    450     ServiceSqlitePaths::from_runtime_context(runtime.context())
    451         .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidPaths))
    452 }
    453 
    454 fn provision_state_directory(runtime: &MycRuntimeContext) -> Result<(), MycStateHostError> {
    455     runtime
    456         .context()
    457         .state_directory_plan()
    458         .and_then(|plan| plan.provision())
    459         .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))
    460 }
    461 
    462 pub(crate) fn require_metadata(
    463     runtime: &MycRuntimeContext,
    464     metadata: &MycStateMetadata,
    465 ) -> Result<(), MycStateHostError> {
    466     let database = metadata.initial_database_metadata();
    467     let identity = metadata.database_identity();
    468     let matches = metadata.matches_runtime(runtime)
    469         && database.service() == runtime.context().service()
    470         && database.instance() == runtime.context().instance()
    471         && database.state_schema_version().get() == MYC_STATE_BASE_SCHEMA_VERSION
    472         && identity.service() == runtime.context().service()
    473         && identity.instance() == runtime.context().instance()
    474         && identity.supported_state_schema_version().get() == MYC_STATE_SCHEMA_VERSION;
    475     matches
    476         .then_some(())
    477         .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))
    478 }
    479 
    480 fn require_migration_build(
    481     metadata: &MycStateMetadata,
    482     build: &MigrationBuildIdentity,
    483 ) -> Result<(), MycStateHostError> {
    484     let versions = metadata.policy_versions();
    485     let matches = build.config_contract_version() == versions.configuration()
    486         && build.state_contract_version() == versions.state()
    487         && build.admin_contract_version() == versions.operator()
    488         && build.status_contract_version() == versions.status();
    489     matches
    490         .then_some(())
    491         .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))
    492 }
    493 
    494 fn exact_initialization_outcome(outcome: MigrationApplicationOutcome) -> bool {
    495     outcome.initial_version() == MYC_STATE_BASE_SCHEMA_VERSION
    496         && outcome.final_version() == MYC_STATE_SCHEMA_VERSION
    497         && outcome.applied_count() == 11
    498 }
    499 
    500 fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool {
    501     outcome.final_version() == MYC_STATE_SCHEMA_VERSION
    502         && matches!(
    503             (outcome.initial_version(), outcome.applied_count()),
    504             (MYC_STATE_BASE_SCHEMA_VERSION, 11)
    505                 | (2, 10)
    506                 | (3, 9)
    507                 | (4, 8)
    508                 | (5, 7)
    509                 | (6, 6)
    510                 | (7, 5)
    511                 | (8, 4)
    512                 | (9, 3)
    513                 | (10, 2)
    514                 | (11, 1)
    515                 | (MYC_STATE_SCHEMA_VERSION, 0)
    516         )
    517 }
    518 
    519 pub(crate) fn catalogs() -> Result<
    520     (
    521         radroots_service_sqlite::MigrationCatalog,
    522         radroots_service_sqlite::SchemaCatalog,
    523     ),
    524     MycStateHostError,
    525 > {
    526     let migrations = myc_migration_catalog()
    527         .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?;
    528     let schema =
    529         myc_schema_catalog().map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?;
    530     validate_myc_state_catalogs(&migrations, &schema)
    531         .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?;
    532     Ok((migrations, schema))
    533 }
    534 
    535 fn initialize_empty_catalog<'a>(
    536     _initializer: &'a mut ServiceSqliteInitializer<'_>,
    537 ) -> ServiceSqliteInitializerFuture<'a, core::convert::Infallible> {
    538     Box::pin(async { Ok(()) })
    539 }