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 }