myc

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

doctor_v1.rs (18885B)


      1 //! Bounded active-doctor orchestration and safe structured evidence.
      2 
      3 use core::{fmt, future::Future, pin::Pin, time::Duration};
      4 use std::error::Error;
      5 
      6 use radroots_runtime_paths::InstanceId;
      7 use serde::Serialize;
      8 
      9 use crate::MycRuntimeContext;
     10 
     11 /// Myc doctor wire-contract version.
     12 pub const MYC_DOCTOR_CONTRACT_VERSION: u32 = 1;
     13 /// Exact number of governed Myc doctor checks.
     14 pub const MYC_DOCTOR_CHECK_COUNT: usize = 13;
     15 /// Maximum encoded size of one safe summary.
     16 pub const MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize = 256;
     17 /// Maximum encoded size of the complete canonical doctor report.
     18 pub const MYC_DOCTOR_REPORT_MAX_UTF8_BYTES: usize = 8_192;
     19 
     20 const MYC_SERVICE: &str = "myc";
     21 const DOCTOR_FAILURE_EXIT_CODE: u8 = 6;
     22 const _: () = {
     23     assert!("check passed".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
     24     assert!("check failed".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
     25     assert!("check timed out".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
     26     assert!("optional check skipped".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
     27 };
     28 
     29 /// The closed Myc doctor inventory.
     30 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
     31 pub enum MycDoctorCheckId {
     32     PathsPermissions,
     33     WriterLock,
     34     SqliteSchema,
     35     SqliteIntegrity,
     36     SqliteFreeSpace,
     37     IdentityBinding,
     38     SignerProvider,
     39     AdminBindPolicy,
     40     OperationsBindPolicy,
     41     NetworkPolicy,
     42     RequiredRelays,
     43     OutboxInvariants,
     44     ClockSkew,
     45 }
     46 
     47 impl MycDoctorCheckId {
     48     const fn as_str(self) -> &'static str {
     49         match self {
     50             Self::PathsPermissions => "paths_permissions",
     51             Self::WriterLock => "writer_lock",
     52             Self::SqliteSchema => "sqlite_schema",
     53             Self::SqliteIntegrity => "sqlite_integrity",
     54             Self::SqliteFreeSpace => "sqlite_free_space",
     55             Self::IdentityBinding => "identity_binding",
     56             Self::SignerProvider => "signer_provider",
     57             Self::AdminBindPolicy => "admin_bind_policy",
     58             Self::OperationsBindPolicy => "operations_bind_policy",
     59             Self::NetworkPolicy => "network_policy",
     60             Self::RequiredRelays => "required_relays",
     61             Self::OutboxInvariants => "outbox_invariants",
     62             Self::ClockSkew => "clock_skew",
     63         }
     64     }
     65 }
     66 
     67 /// Stable operator action associated with one doctor check.
     68 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     69 pub enum MycDoctorRemediationCode {
     70     CorrectPathPolicy,
     71     ReleaseWriterLock,
     72     RepairSchema,
     73     RestoreVerifiedState,
     74     FreeStateDiskSpace,
     75     RestoreIdentityBinding,
     76     RepairSignerProvider,
     77     CorrectAdminBindPolicy,
     78     CorrectOperationsBindPolicy,
     79     CorrectNetworkPolicy,
     80     RestoreRequiredRelays,
     81     RepairOutboxState,
     82     CorrectClock,
     83 }
     84 
     85 impl MycDoctorRemediationCode {
     86     const fn as_str(self) -> &'static str {
     87         match self {
     88             Self::CorrectPathPolicy => "correct_path_policy",
     89             Self::ReleaseWriterLock => "release_writer_lock",
     90             Self::RepairSchema => "repair_schema",
     91             Self::RestoreVerifiedState => "restore_verified_state",
     92             Self::FreeStateDiskSpace => "free_state_disk_space",
     93             Self::RestoreIdentityBinding => "restore_identity_binding",
     94             Self::RepairSignerProvider => "repair_signer_provider",
     95             Self::CorrectAdminBindPolicy => "correct_admin_bind_policy",
     96             Self::CorrectOperationsBindPolicy => "correct_operations_bind_policy",
     97             Self::CorrectNetworkPolicy => "correct_network_policy",
     98             Self::RestoreRequiredRelays => "restore_required_relays",
     99             Self::RepairOutboxState => "repair_outbox_state",
    100             Self::CorrectClock => "correct_clock",
    101         }
    102     }
    103 }
    104 
    105 /// Immutable authority for one check's requirement, deadline, and remediation.
    106 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    107 pub struct MycDoctorCheckDefinition {
    108     id: MycDoctorCheckId,
    109     required: bool,
    110     deadline_ms: u64,
    111     remediation_code: MycDoctorRemediationCode,
    112     scope: &'static [&'static str],
    113 }
    114 
    115 impl MycDoctorCheckDefinition {
    116     const fn new(
    117         id: MycDoctorCheckId,
    118         required: bool,
    119         deadline_ms: u64,
    120         remediation_code: MycDoctorRemediationCode,
    121         scope: &'static [&'static str],
    122     ) -> Self {
    123         Self {
    124             id,
    125             required,
    126             deadline_ms,
    127             remediation_code,
    128             scope,
    129         }
    130     }
    131 
    132     /// Returns the governed check identifier.
    133     #[must_use]
    134     pub const fn id(self) -> MycDoctorCheckId {
    135         self.id
    136     }
    137 
    138     /// Returns whether a non-pass result fails the doctor command.
    139     #[must_use]
    140     pub const fn required(self) -> bool {
    141         self.required
    142     }
    143 
    144     /// Returns the exact per-check deadline in milliseconds.
    145     #[must_use]
    146     pub const fn deadline_ms(self) -> u64 {
    147         self.deadline_ms
    148     }
    149 
    150     /// Returns the fixed, safe operator remediation classification.
    151     #[must_use]
    152     pub const fn remediation_code(self) -> MycDoctorRemediationCode {
    153         self.remediation_code
    154     }
    155 
    156     /// Returns the exact safe evidence facets owned by this check.
    157     #[must_use]
    158     pub const fn scope(self) -> &'static [&'static str] {
    159         self.scope
    160     }
    161 }
    162 
    163 const CHECK_DEFINITIONS: [MycDoctorCheckDefinition; MYC_DOCTOR_CHECK_COUNT] = [
    164     MycDoctorCheckDefinition::new(
    165         MycDoctorCheckId::PathsPermissions,
    166         true,
    167         2_000,
    168         MycDoctorRemediationCode::CorrectPathPolicy,
    169         &["resolved_path_containment", "owner", "type", "mode"],
    170     ),
    171     MycDoctorCheckDefinition::new(
    172         MycDoctorCheckId::WriterLock,
    173         true,
    174         2_000,
    175         MycDoctorRemediationCode::ReleaseWriterLock,
    176         &["state_directory_binding", "writer_lock_state"],
    177     ),
    178     MycDoctorCheckDefinition::new(
    179         MycDoctorCheckId::SqliteSchema,
    180         true,
    181         5_000,
    182         MycDoctorRemediationCode::RepairSchema,
    183         &["metadata_identity", "migration_history", "schema_catalog"],
    184     ),
    185     MycDoctorCheckDefinition::new(
    186         MycDoctorCheckId::SqliteIntegrity,
    187         true,
    188         15_000,
    189         MycDoctorRemediationCode::RestoreVerifiedState,
    190         &["integrity_check", "foreign_key_check"],
    191     ),
    192     MycDoctorCheckDefinition::new(
    193         MycDoctorCheckId::SqliteFreeSpace,
    194         true,
    195         2_000,
    196         MycDoctorRemediationCode::FreeStateDiskSpace,
    197         &["state_filesystem_capacity", "minimum_free_bytes"],
    198     ),
    199     MycDoctorCheckDefinition::new(
    200         MycDoctorCheckId::IdentityBinding,
    201         true,
    202         2_000,
    203         MycDoctorRemediationCode::RestoreIdentityBinding,
    204         &[
    205             "envelope_contract",
    206             "credential_reference",
    207             "public_identity",
    208         ],
    209     ),
    210     MycDoctorCheckDefinition::new(
    211         MycDoctorCheckId::SignerProvider,
    212         true,
    213         15_000,
    214         MycDoctorRemediationCode::RepairSignerProvider,
    215         &[
    216             "capability",
    217             "contract_version",
    218             "identity",
    219             "correlation",
    220             "deadline",
    221         ],
    222     ),
    223     MycDoctorCheckDefinition::new(
    224         MycDoctorCheckId::AdminBindPolicy,
    225         true,
    226         2_000,
    227         MycDoctorRemediationCode::CorrectAdminBindPolicy,
    228         &["unix_socket_path", "socket_mode", "peer_authorization"],
    229     ),
    230     MycDoctorCheckDefinition::new(
    231         MycDoctorCheckId::OperationsBindPolicy,
    232         true,
    233         2_000,
    234         MycDoctorRemediationCode::CorrectOperationsBindPolicy,
    235         &["enabled_posture", "listen_address", "bind_policy"],
    236     ),
    237     MycDoctorCheckDefinition::new(
    238         MycDoctorCheckId::NetworkPolicy,
    239         true,
    240         2_000,
    241         MycDoctorRemediationCode::CorrectNetworkPolicy,
    242         &["dns_policy", "tls_policy", "relay_url_policy"],
    243     ),
    244     MycDoctorCheckDefinition::new(
    245         MycDoctorCheckId::RequiredRelays,
    246         true,
    247         15_000,
    248         MycDoctorRemediationCode::RestoreRequiredRelays,
    249         &[
    250             "required_read_relays",
    251             "required_write_relays",
    252             "connect_deadline",
    253         ],
    254     ),
    255     MycDoctorCheckDefinition::new(
    256         MycDoctorCheckId::OutboxInvariants,
    257         true,
    258         5_000,
    259         MycDoctorRemediationCode::RepairOutboxState,
    260         &["claim_invariants", "retry_state", "exact_response_bytes"],
    261     ),
    262     MycDoctorCheckDefinition::new(
    263         MycDoctorCheckId::ClockSkew,
    264         false,
    265         5_000,
    266         MycDoctorRemediationCode::CorrectClock,
    267         &["wall_clock_skew"],
    268     ),
    269 ];
    270 
    271 /// Returns the exact ordered doctor inventory.
    272 #[must_use]
    273 pub const fn myc_doctor_check_definitions()
    274 -> &'static [MycDoctorCheckDefinition; MYC_DOCTOR_CHECK_COUNT] {
    275     &CHECK_DEFINITIONS
    276 }
    277 
    278 /// A closed result supplied by one bounded check implementation.
    279 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    280 pub enum MycDoctorObservation {
    281     Pass,
    282     Fail,
    283     Skipped,
    284 }
    285 
    286 /// Future returned by one doctor probe.
    287 pub type MycDoctorFuture<'a> = Pin<Box<dyn Future<Output = MycDoctorObservation> + Send + 'a>>;
    288 
    289 /// Executes each active check without receiving report-construction authority.
    290 ///
    291 /// `Pass` is permitted only after every facet in [`MycDoctorCheckDefinition::scope`]
    292 /// is proven. Implementations must be cancellation-safe: the returned future
    293 /// owns its work, and dropping it at the deadline must not leave detached work
    294 /// or mutation running.
    295 pub trait MycDoctorProbe: Send + Sync {
    296     /// Runs one exact check. Raw errors, paths, and arbitrary summaries cannot
    297     /// cross this boundary.
    298     fn probe(&self, definition: MycDoctorCheckDefinition) -> MycDoctorFuture<'_>;
    299 }
    300 
    301 /// Stable status of one completed check.
    302 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    303 pub enum MycDoctorCheckStatus {
    304     Pass,
    305     Fail,
    306     Timeout,
    307     Skipped,
    308 }
    309 
    310 impl MycDoctorCheckStatus {
    311     const fn as_str(self) -> &'static str {
    312         match self {
    313             Self::Pass => "pass",
    314             Self::Fail => "fail",
    315             Self::Timeout => "timeout",
    316             Self::Skipped => "skipped",
    317         }
    318     }
    319 
    320     const fn summary(self) -> &'static str {
    321         match self {
    322             Self::Pass => "check passed",
    323             Self::Fail => "check failed",
    324             Self::Timeout => "check timed out",
    325             Self::Skipped => "optional check skipped",
    326         }
    327     }
    328 }
    329 
    330 /// Stable aggregate doctor status.
    331 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    332 pub enum MycDoctorAggregateStatus {
    333     Pass,
    334     Degraded,
    335     Fail,
    336 }
    337 
    338 impl MycDoctorAggregateStatus {
    339     const fn as_str(self) -> &'static str {
    340         match self {
    341             Self::Pass => "pass",
    342             Self::Degraded => "degraded",
    343             Self::Fail => "fail",
    344         }
    345     }
    346 }
    347 
    348 /// One sealed structured doctor result.
    349 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    350 pub struct MycDoctorCheckResult {
    351     definition: MycDoctorCheckDefinition,
    352     status: MycDoctorCheckStatus,
    353 }
    354 
    355 impl MycDoctorCheckResult {
    356     /// Returns the exact check definition.
    357     #[must_use]
    358     pub const fn definition(self) -> MycDoctorCheckDefinition {
    359         self.definition
    360     }
    361 
    362     /// Returns the admitted check status.
    363     #[must_use]
    364     pub const fn status(self) -> MycDoctorCheckStatus {
    365         self.status
    366     }
    367 
    368     /// Returns the fixed content-free summary.
    369     #[must_use]
    370     pub const fn summary(self) -> &'static str {
    371         self.status.summary()
    372     }
    373 }
    374 
    375 /// Stable source-free doctor construction failures.
    376 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    377 pub enum MycDoctorErrorKind {
    378     Encoding,
    379     OutputTooLarge,
    380 }
    381 
    382 impl MycDoctorErrorKind {
    383     const fn message(self) -> &'static str {
    384         match self {
    385             Self::Encoding => "Myc doctor output encoding failed",
    386             Self::OutputTooLarge => "Myc doctor output exceeds its byte limit",
    387         }
    388     }
    389 }
    390 
    391 /// One redacted doctor construction failure.
    392 #[derive(Clone, Copy, PartialEq, Eq)]
    393 pub struct MycDoctorError {
    394     kind: MycDoctorErrorKind,
    395 }
    396 
    397 impl MycDoctorError {
    398     const fn new(kind: MycDoctorErrorKind) -> Self {
    399         Self { kind }
    400     }
    401 
    402     /// Returns the stable error classification.
    403     #[must_use]
    404     pub const fn kind(self) -> MycDoctorErrorKind {
    405         self.kind
    406     }
    407 }
    408 
    409 impl fmt::Debug for MycDoctorError {
    410     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    411         formatter
    412             .debug_struct("MycDoctorError")
    413             .field("kind", &self.kind)
    414             .finish()
    415     }
    416 }
    417 
    418 impl fmt::Display for MycDoctorError {
    419     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    420         formatter.write_str(self.kind.message())
    421     }
    422 }
    423 
    424 impl Error for MycDoctorError {}
    425 
    426 /// One immutable, bounded, canonical Myc doctor report.
    427 ///
    428 /// Construction remains inside [`run_myc_doctor`]:
    429 ///
    430 /// ```compile_fail
    431 /// use myc::{MycDoctorAggregateStatus, MycDoctorReport};
    432 ///
    433 /// let _ = MycDoctorReport {
    434 ///     instance: todo!(),
    435 ///     status: MycDoctorAggregateStatus::Pass,
    436 ///     checks: Box::new([]),
    437 ///     canonical_json: Box::new([]),
    438 /// };
    439 /// ```
    440 pub struct MycDoctorReport {
    441     instance: InstanceId,
    442     status: MycDoctorAggregateStatus,
    443     checks: Box<[MycDoctorCheckResult]>,
    444     canonical_json: Box<[u8]>,
    445 }
    446 
    447 impl MycDoctorReport {
    448     /// Returns the fixed service identifier.
    449     #[must_use]
    450     pub const fn service(&self) -> &'static str {
    451         MYC_SERVICE
    452     }
    453 
    454     /// Returns the validated instance identifier admitted into the report.
    455     #[must_use]
    456     pub const fn instance(&self) -> &InstanceId {
    457         &self.instance
    458     }
    459 
    460     /// Returns the aggregate result.
    461     #[must_use]
    462     pub const fn status(&self) -> MycDoctorAggregateStatus {
    463         self.status
    464     }
    465 
    466     /// Returns the ordered complete check inventory.
    467     #[must_use]
    468     pub fn checks(&self) -> &[MycDoctorCheckResult] {
    469         &self.checks
    470     }
    471 
    472     /// Returns exact compact UTF-8 JSON in the shared v1 field order.
    473     #[must_use]
    474     pub fn canonical_json(&self) -> &[u8] {
    475         &self.canonical_json
    476     }
    477 
    478     /// Returns exit 6 only when a required check failed or timed out.
    479     #[must_use]
    480     pub const fn exit_code(&self) -> u8 {
    481         match self.status {
    482             MycDoctorAggregateStatus::Fail => DOCTOR_FAILURE_EXIT_CODE,
    483             MycDoctorAggregateStatus::Pass | MycDoctorAggregateStatus::Degraded => 0,
    484         }
    485     }
    486 }
    487 
    488 impl fmt::Debug for MycDoctorReport {
    489     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    490         formatter
    491             .debug_struct("MycDoctorReport")
    492             .field("service", &MYC_SERVICE)
    493             .field("instance", &"[redacted]")
    494             .field("status", &self.status)
    495             .field("check_count", &self.checks.len())
    496             .field("canonical_json", &"[redacted]")
    497             .finish()
    498     }
    499 }
    500 
    501 /// Runs every governed check in exact contract order under its fixed deadline.
    502 ///
    503 /// Probe implementations retain operation-specific filesystem, SQLite,
    504 /// provider, listener, network, relay, and clock authority. This orchestrator
    505 /// accepts only a closed result and cannot serialize their paths or raw errors.
    506 pub async fn run_myc_doctor(
    507     context: &MycRuntimeContext,
    508     probe: &(impl MycDoctorProbe + ?Sized),
    509 ) -> Result<MycDoctorReport, MycDoctorError> {
    510     let mut checks = Vec::with_capacity(MYC_DOCTOR_CHECK_COUNT);
    511     for definition in CHECK_DEFINITIONS {
    512         let status = match tokio::time::timeout(
    513             Duration::from_millis(definition.deadline_ms),
    514             probe.probe(definition),
    515         )
    516         .await
    517         {
    518             Ok(MycDoctorObservation::Pass) => MycDoctorCheckStatus::Pass,
    519             Ok(MycDoctorObservation::Fail) => MycDoctorCheckStatus::Fail,
    520             Ok(MycDoctorObservation::Skipped) if !definition.required => {
    521                 MycDoctorCheckStatus::Skipped
    522             }
    523             Ok(MycDoctorObservation::Skipped) => MycDoctorCheckStatus::Fail,
    524             Err(_) => MycDoctorCheckStatus::Timeout,
    525         };
    526         checks.push(MycDoctorCheckResult { definition, status });
    527     }
    528     let checks = checks.into_boxed_slice();
    529     let status = aggregate_status(&checks);
    530     let instance = context.context().instance().clone();
    531     let canonical_json = encode_report(&instance, status, &checks)?;
    532 
    533     Ok(MycDoctorReport {
    534         instance,
    535         status,
    536         checks,
    537         canonical_json,
    538     })
    539 }
    540 
    541 fn aggregate_status(checks: &[MycDoctorCheckResult]) -> MycDoctorAggregateStatus {
    542     if checks
    543         .iter()
    544         .any(|result| result.definition.required && result.status != MycDoctorCheckStatus::Pass)
    545     {
    546         MycDoctorAggregateStatus::Fail
    547     } else if checks
    548         .iter()
    549         .any(|result| result.status != MycDoctorCheckStatus::Pass)
    550     {
    551         MycDoctorAggregateStatus::Degraded
    552     } else {
    553         MycDoctorAggregateStatus::Pass
    554     }
    555 }
    556 
    557 #[derive(Serialize)]
    558 struct DoctorWireReport<'a> {
    559     contract_version: u32,
    560     service: &'static str,
    561     instance: &'a str,
    562     status: &'static str,
    563     checks: Vec<DoctorWireCheck>,
    564 }
    565 
    566 #[derive(Serialize)]
    567 struct DoctorWireCheck {
    568     id: &'static str,
    569     status: &'static str,
    570     required: bool,
    571     deadline_ms: u64,
    572     summary: &'static str,
    573     remediation_code: &'static str,
    574 }
    575 
    576 fn encode_report(
    577     instance: &InstanceId,
    578     status: MycDoctorAggregateStatus,
    579     checks: &[MycDoctorCheckResult],
    580 ) -> Result<Box<[u8]>, MycDoctorError> {
    581     let checks = checks
    582         .iter()
    583         .map(|result| DoctorWireCheck {
    584             id: result.definition.id.as_str(),
    585             status: result.status.as_str(),
    586             required: result.definition.required,
    587             deadline_ms: result.definition.deadline_ms,
    588             summary: result.status.summary(),
    589             remediation_code: result.definition.remediation_code.as_str(),
    590         })
    591         .collect();
    592     let encoded = serde_json::to_vec(&DoctorWireReport {
    593         contract_version: MYC_DOCTOR_CONTRACT_VERSION,
    594         service: MYC_SERVICE,
    595         instance: instance.as_str(),
    596         status: status.as_str(),
    597         checks,
    598     })
    599     .map_err(|_| MycDoctorError::new(MycDoctorErrorKind::Encoding))?;
    600     if encoded.len() > MYC_DOCTOR_REPORT_MAX_UTF8_BYTES {
    601         return Err(MycDoctorError::new(MycDoctorErrorKind::OutputTooLarge));
    602     }
    603     Ok(encoded.into_boxed_slice())
    604 }
    605 
    606 #[cfg(test)]
    607 mod tests {
    608     use std::error::Error;
    609 
    610     use super::{MycDoctorError, MycDoctorErrorKind};
    611 
    612     #[test]
    613     fn errors_are_source_free_and_content_free() {
    614         for kind in [
    615             MycDoctorErrorKind::Encoding,
    616             MycDoctorErrorKind::OutputTooLarge,
    617         ] {
    618             let error = MycDoctorError::new(kind);
    619             assert!(Error::source(&error).is_none());
    620             let rendered = format!("{error} {error:?}");
    621             for forbidden in ["/private", "secret", "relay", "sqlite"] {
    622                 assert!(!rendered.to_ascii_lowercase().contains(forbidden));
    623             }
    624         }
    625     }
    626 }