myc

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

commit 4a73308a1cfd57fbe37dc89068565a71721d9a48
parent 343a9f76c1298f9a8d1e2b37cc1f3c1bff54d3b2
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 05:42:23 +0000

doctor: freeze bounded active check contract

Diffstat:
MAGENTS.md | 8++++++++
MREADME | 11+++++++++++
Mcontracts/api_baselines/myc.txt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/services_hardening/operator_contract.v1.json | 37++++++++++++++++++++++++-------------
Asrc/doctor_v1.rs | 626+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 8++++++++
Mtests/package_boundary.rs | 44+++++++++++++++++++++++++++++++++++++++++++-
Mtests/services_hardening_contracts.rs | 46+++++++++++++++++++++++++++++++++-------------
Atests/services_hardening_doctor.rs | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1133 insertions(+), 27 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -114,6 +114,14 @@ Rekey, replace, and every other live mutation have no direct-state fallback. Do not reparse process arguments or let a live CLI plan obtain SQLite, provider, relay, task, signal, or runtime authority. +- Step 153 freezes one ordered 13-check doctor engine. Check adapters retain + their operation-specific authority and may return only closed observations; + the engine owns exact deadlines, required/optional aggregation, fixed safe + summaries/remediation codes, bounded canonical JSON, and exit 6 for required + failure or timeout. Do not admit raw errors, paths, URLs, keys, credentials, + arbitrary details, unbounded output, detached probe work, or + liveness/readiness probe authority. A pass must prove every contracted scope + facet, and deadline cancellation must stop or synchronously own cleanup. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/README b/README @@ -60,6 +60,17 @@ read-only status, backup, and public-identity operations may fall back when a later executor proves the daemon writer lock is free. Rekey and replace never fall back to direct state access, and no live plan carries SQLite authority. +The active doctor boundary executes the exact 13-check operator inventory in +contract order under fixed per-check deadlines. Check implementations retain +their filesystem, SQLite, provider, bind, network, relay, and clock authority; +only closed pass/fail/skipped observations cross into the report builder. The +builder enforces required-check semantics, returns exit 6 for required failure +or timeout, and emits at most 8,192 bytes of compact canonical JSON using only +fixed summaries and remediation codes. Raw errors, paths, relay URLs, +credentials, public keys, and arbitrary detail strings cannot enter the report. +A pass requires every machine-listed scope facet. Probe futures own their work, +must stop safely when dropped at deadline, and may not detach later mutation. + `parse_myc_config_v1` caps original bytes before decoding, checks the schema header before closed contract admission, rejects duplicate, null, unknown, and semantically inconsistent input, and returns an immutable document plus a diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -304,6 +304,50 @@ pub myc::MycDiscoveryStateErrorKind::InvalidProjection pub myc::MycDiscoveryStateErrorKind::TooLarge impl myc::MycDiscoveryStateErrorKind pub const fn myc::MycDiscoveryStateErrorKind::code(self) -> &'static str +pub enum myc::MycDoctorAggregateStatus +pub myc::MycDoctorAggregateStatus::Degraded +pub myc::MycDoctorAggregateStatus::Fail +pub myc::MycDoctorAggregateStatus::Pass +pub enum myc::MycDoctorCheckId +pub myc::MycDoctorCheckId::AdminBindPolicy +pub myc::MycDoctorCheckId::ClockSkew +pub myc::MycDoctorCheckId::IdentityBinding +pub myc::MycDoctorCheckId::NetworkPolicy +pub myc::MycDoctorCheckId::OperationsBindPolicy +pub myc::MycDoctorCheckId::OutboxInvariants +pub myc::MycDoctorCheckId::PathsPermissions +pub myc::MycDoctorCheckId::RequiredRelays +pub myc::MycDoctorCheckId::SignerProvider +pub myc::MycDoctorCheckId::SqliteFreeSpace +pub myc::MycDoctorCheckId::SqliteIntegrity +pub myc::MycDoctorCheckId::SqliteSchema +pub myc::MycDoctorCheckId::WriterLock +pub enum myc::MycDoctorCheckStatus +pub myc::MycDoctorCheckStatus::Fail +pub myc::MycDoctorCheckStatus::Pass +pub myc::MycDoctorCheckStatus::Skipped +pub myc::MycDoctorCheckStatus::Timeout +pub enum myc::MycDoctorErrorKind +pub myc::MycDoctorErrorKind::Encoding +pub myc::MycDoctorErrorKind::OutputTooLarge +pub enum myc::MycDoctorObservation +pub myc::MycDoctorObservation::Fail +pub myc::MycDoctorObservation::Pass +pub myc::MycDoctorObservation::Skipped +pub enum myc::MycDoctorRemediationCode +pub myc::MycDoctorRemediationCode::CorrectAdminBindPolicy +pub myc::MycDoctorRemediationCode::CorrectClock +pub myc::MycDoctorRemediationCode::CorrectNetworkPolicy +pub myc::MycDoctorRemediationCode::CorrectOperationsBindPolicy +pub myc::MycDoctorRemediationCode::CorrectPathPolicy +pub myc::MycDoctorRemediationCode::FreeStateDiskSpace +pub myc::MycDoctorRemediationCode::ReleaseWriterLock +pub myc::MycDoctorRemediationCode::RepairOutboxState +pub myc::MycDoctorRemediationCode::RepairSchema +pub myc::MycDoctorRemediationCode::RepairSignerProvider +pub myc::MycDoctorRemediationCode::RestoreIdentityBinding +pub myc::MycDoctorRemediationCode::RestoreRequiredRelays +pub myc::MycDoctorRemediationCode::RestoreVerifiedState pub enum myc::MycEncryptedIdentityEnvelopeErrorKind pub myc::MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists pub myc::MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch @@ -1008,6 +1052,36 @@ impl core::fmt::Debug for myc::MycDiscoveryStateError pub fn myc::MycDiscoveryStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for myc::MycDiscoveryStateError pub fn myc::MycDiscoveryStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycDoctorCheckDefinition +impl myc::MycDoctorCheckDefinition +pub const fn myc::MycDoctorCheckDefinition::deadline_ms(self) -> u64 +pub const fn myc::MycDoctorCheckDefinition::id(self) -> myc::MycDoctorCheckId +pub const fn myc::MycDoctorCheckDefinition::remediation_code(self) -> myc::MycDoctorRemediationCode +pub const fn myc::MycDoctorCheckDefinition::required(self) -> bool +pub const fn myc::MycDoctorCheckDefinition::scope(self) -> &'static [&'static str] +pub struct myc::MycDoctorCheckResult +impl myc::MycDoctorCheckResult +pub const fn myc::MycDoctorCheckResult::definition(self) -> myc::MycDoctorCheckDefinition +pub const fn myc::MycDoctorCheckResult::status(self) -> myc::MycDoctorCheckStatus +pub const fn myc::MycDoctorCheckResult::summary(self) -> &'static str +pub struct myc::MycDoctorError +impl myc::MycDoctorError +pub const fn myc::MycDoctorError::kind(self) -> myc::MycDoctorErrorKind +impl core::error::Error for myc::MycDoctorError +impl core::fmt::Debug for myc::MycDoctorError +pub fn myc::MycDoctorError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycDoctorError +pub fn myc::MycDoctorError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycDoctorReport +impl myc::MycDoctorReport +pub fn myc::MycDoctorReport::canonical_json(&self) -> &[u8] +pub fn myc::MycDoctorReport::checks(&self) -> &[myc::MycDoctorCheckResult] +pub const fn myc::MycDoctorReport::exit_code(&self) -> u8 +pub const fn myc::MycDoctorReport::instance(&self) -> &radroots_runtime_paths::identifier::InstanceId +pub const fn myc::MycDoctorReport::service(&self) -> &'static str +pub const fn myc::MycDoctorReport::status(&self) -> myc::MycDoctorAggregateStatus +impl core::fmt::Debug for myc::MycDoctorReport +pub fn myc::MycDoctorReport::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycEffectiveConfigV1 impl myc::MycEffectiveConfigV1 pub fn myc::MycEffectiveConfigV1::canonical_json(&self) -> &str @@ -1653,6 +1727,10 @@ pub const myc::MYC_DELIVERY_RELAY_ID_MAX_BYTES: usize pub const myc::MYC_DELIVERY_RETRY_JITTER_MAX_MS: u64 pub const myc::MYC_DELIVERY_TARGET_MAX_COUNT: usize pub const myc::MYC_DISCOVERY_DOCUMENT_MAX_BYTES: usize +pub const myc::MYC_DOCTOR_CHECK_COUNT: usize +pub const myc::MYC_DOCTOR_CONTRACT_VERSION: u32 +pub const myc::MYC_DOCTOR_REPORT_MAX_UTF8_BYTES: usize +pub const myc::MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize pub const myc::MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32 pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize @@ -1715,12 +1793,15 @@ pub const myc::MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize pub const myc::MYC_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 pub trait myc::MycAdminHandler: core::marker::Send + core::marker::Sync + 'static pub fn myc::MycAdminHandler::handle<'a>(&'a self, myc::MycAdminRequestDocument) -> myc::MycAdminFuture<'a> +pub trait myc::MycDoctorProbe: core::marker::Send + core::marker::Sync +pub fn myc::MycDoctorProbe::probe(&self, myc::MycDoctorCheckDefinition) -> myc::MycDoctorFuture<'_> pub fn myc::admit_myc_nip46_event(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Event, myc::MycNip46AdmissionError> pub fn myc::admit_myc_nip46_request(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Request, myc::MycNip46AdmissionError> pub fn myc::bind_myc_nip46_replay(myc::MycVerifiedNip46Event, myc::MycVerifiedNip46Request) -> core::result::Result<myc::MycReplayBoundNip46Request, myc::MycSignerRequestError> pub fn myc::build_myc_admin_router<H>(alloc::sync::Arc<H>) -> core::result::Result<myc::MycAdminRouter, myc::MycAdminRouterError> where H: myc::MycAdminHandler pub async fn myc::finalize_myc_state_restore(myc::MycStagedStateRestore) -> core::result::Result<(), myc::MycStateMaintenanceError> pub async fn myc::initialize_myc_state(&myc::MycRuntimeContext, &myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), myc::MycStateHostError> +pub const fn myc::myc_doctor_check_definitions() -> &'static [myc::MycDoctorCheckDefinition; 13] pub fn myc::myc_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, myc::MycStateCatalogError> pub fn myc::myc_schema_catalog() -> core::result::Result<radroots_service_sqlite::integrity::catalog::SchemaCatalog, myc::MycStateCatalogError> pub fn myc::open_myc_encrypted_identity(&myc::MycProviderBinding, &myc::MycWrappingCredential) -> core::result::Result<myc::MycDecryptedIdentity, myc::MycEncryptedIdentityEnvelopeError> @@ -1736,9 +1817,11 @@ pub fn myc::prepare_myc_nip46_work(myc::MycPreparedNip46Request, myc::MycSignerR pub fn myc::provision_myc_encrypted_identity(&myc::MycProviderBinding, &myc::MycWrappingCredential, myc::MycEncryptedIdentityProvisioningMaterial) -> core::result::Result<myc::MycDecryptedIdentity, myc::MycEncryptedIdentityEnvelopeError> pub fn myc::resolve_myc_runtime_context(&radroots_runtime_paths::roots::RadrootsPathResolver, &myc::MycCliInvocationV1) -> core::result::Result<myc::MycRuntimeContext, myc::MycRuntimeContextError> pub fn myc::resolve_myc_wrapping_credential(&myc::MycRuntimeContext, &myc::MycProviderBinding) -> core::result::Result<myc::MycWrappingCredential, myc::MycCredentialResolutionError> +pub async fn myc::run_myc_doctor(&myc::MycRuntimeContext, &impl myc::MycDoctorProbe + ?core::marker::Sized) -> core::result::Result<myc::MycDoctorReport, myc::MycDoctorError> pub async fn myc::stage_myc_state_restore(&myc::MycRuntimeContext, &myc::MycStateMetadata, myc::MycVerifiedStateBackup) -> core::result::Result<myc::MycStagedStateRestore, myc::MycStateMaintenanceError> pub fn myc::validate_myc_state_catalogs(&radroots_service_sqlite::migration::MigrationCatalog, &radroots_service_sqlite::integrity::catalog::SchemaCatalog) -> core::result::Result<(), myc::MycStateCatalogError> pub fn myc::verify_myc_nip46_event(myc::MycBoundedNip46Event, &myc::MycProviderBinding, myc::MycNip46ObservedAtUnixSeconds, myc::MycNip46AuthoredTimePolicy) -> core::result::Result<myc::MycVerifiedNip46Event, myc::MycNip46VerificationError> pub fn myc::verify_myc_nip46_request(myc::MycBoundedNip46Request) -> core::result::Result<myc::MycVerifiedNip46Request, myc::MycNip46VerificationError> pub fn myc::verify_myc_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &myc::MycStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<myc::MycVerifiedStateBackup, myc::MycStateMaintenanceError> pub type myc::MycAdminFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::result::Result<myc::MycAdminResponseDocument, myc::MycAdminHandlerError>> + core::marker::Send + 'a)>> +pub type myc::MycDoctorFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = myc::MycDoctorObservation> + core::marker::Send + 'a)>> diff --git a/contracts/services_hardening/operator_contract.v1.json b/contracts/services_hardening/operator_contract.v1.json @@ -272,20 +272,31 @@ "doctor": { "shared_schema": "radroots.service.doctor.v1", "contract_version": 1, + "execution": "ordered", + "pass_requires_all_scope": true, + "probe_future_cancellation": "drop_stops_or_owns_cleanup", + "detached_probe_work": false, + "statuses": ["pass", "fail", "timeout", "skipped"], + "aggregate_statuses": ["pass", "degraded", "fail"], + "required_skipped": "forbidden", + "summary_max_utf8_bytes": 256, + "report_max_utf8_bytes": 8192, + "raw_error_or_path_allowed": false, + "required_fail_or_timeout_exit": 6, "checks": [ - { "id": "paths_permissions", "required": true }, - { "id": "writer_lock", "required": true }, - { "id": "sqlite_schema", "required": true }, - { "id": "sqlite_integrity", "required": true }, - { "id": "sqlite_free_space", "required": true }, - { "id": "identity_binding", "required": true }, - { "id": "signer_provider", "required": true }, - { "id": "admin_bind_policy", "required": true }, - { "id": "operations_bind_policy", "required": true }, - { "id": "network_policy", "required": true }, - { "id": "required_relays", "required": true }, - { "id": "outbox_invariants", "required": true }, - { "id": "clock_skew", "required": false } + { "id": "paths_permissions", "required": true, "deadline_ms": 2000, "remediation_code": "correct_path_policy", "scope": ["resolved_path_containment", "owner", "type", "mode"] }, + { "id": "writer_lock", "required": true, "deadline_ms": 2000, "remediation_code": "release_writer_lock", "scope": ["state_directory_binding", "writer_lock_state"] }, + { "id": "sqlite_schema", "required": true, "deadline_ms": 5000, "remediation_code": "repair_schema", "scope": ["metadata_identity", "migration_history", "schema_catalog"] }, + { "id": "sqlite_integrity", "required": true, "deadline_ms": 15000, "remediation_code": "restore_verified_state", "scope": ["integrity_check", "foreign_key_check"] }, + { "id": "sqlite_free_space", "required": true, "deadline_ms": 2000, "remediation_code": "free_state_disk_space", "scope": ["state_filesystem_capacity", "minimum_free_bytes"] }, + { "id": "identity_binding", "required": true, "deadline_ms": 2000, "remediation_code": "restore_identity_binding", "scope": ["envelope_contract", "credential_reference", "public_identity"] }, + { "id": "signer_provider", "required": true, "deadline_ms": 15000, "remediation_code": "repair_signer_provider", "scope": ["capability", "contract_version", "identity", "correlation", "deadline"] }, + { "id": "admin_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_admin_bind_policy", "scope": ["unix_socket_path", "socket_mode", "peer_authorization"] }, + { "id": "operations_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_operations_bind_policy", "scope": ["enabled_posture", "listen_address", "bind_policy"] }, + { "id": "network_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_network_policy", "scope": ["dns_policy", "tls_policy", "relay_url_policy"] }, + { "id": "required_relays", "required": true, "deadline_ms": 15000, "remediation_code": "restore_required_relays", "scope": ["required_read_relays", "required_write_relays", "connect_deadline"] }, + { "id": "outbox_invariants", "required": true, "deadline_ms": 5000, "remediation_code": "repair_outbox_state", "scope": ["claim_invariants", "retry_state", "exact_response_bytes"] }, + { "id": "clock_skew", "required": false, "deadline_ms": 5000, "remediation_code": "correct_clock", "scope": ["wall_clock_skew"] } ] }, "exit_codes": [ diff --git a/src/doctor_v1.rs b/src/doctor_v1.rs @@ -0,0 +1,626 @@ +//! Bounded active-doctor orchestration and safe structured evidence. + +use core::{fmt, future::Future, pin::Pin, time::Duration}; +use std::error::Error; + +use radroots_runtime_paths::InstanceId; +use serde::Serialize; + +use crate::MycRuntimeContext; + +/// Myc doctor wire-contract version. +pub const MYC_DOCTOR_CONTRACT_VERSION: u32 = 1; +/// Exact number of governed Myc doctor checks. +pub const MYC_DOCTOR_CHECK_COUNT: usize = 13; +/// Maximum encoded size of one safe summary. +pub const MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize = 256; +/// Maximum encoded size of the complete canonical doctor report. +pub const MYC_DOCTOR_REPORT_MAX_UTF8_BYTES: usize = 8_192; + +const MYC_SERVICE: &str = "myc"; +const DOCTOR_FAILURE_EXIT_CODE: u8 = 6; +const _: () = { + assert!("check passed".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES); + assert!("check failed".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES); + assert!("check timed out".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES); + assert!("optional check skipped".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES); +}; + +/// The closed Myc doctor inventory. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub enum MycDoctorCheckId { + PathsPermissions, + WriterLock, + SqliteSchema, + SqliteIntegrity, + SqliteFreeSpace, + IdentityBinding, + SignerProvider, + AdminBindPolicy, + OperationsBindPolicy, + NetworkPolicy, + RequiredRelays, + OutboxInvariants, + ClockSkew, +} + +impl MycDoctorCheckId { + const fn as_str(self) -> &'static str { + match self { + Self::PathsPermissions => "paths_permissions", + Self::WriterLock => "writer_lock", + Self::SqliteSchema => "sqlite_schema", + Self::SqliteIntegrity => "sqlite_integrity", + Self::SqliteFreeSpace => "sqlite_free_space", + Self::IdentityBinding => "identity_binding", + Self::SignerProvider => "signer_provider", + Self::AdminBindPolicy => "admin_bind_policy", + Self::OperationsBindPolicy => "operations_bind_policy", + Self::NetworkPolicy => "network_policy", + Self::RequiredRelays => "required_relays", + Self::OutboxInvariants => "outbox_invariants", + Self::ClockSkew => "clock_skew", + } + } +} + +/// Stable operator action associated with one doctor check. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycDoctorRemediationCode { + CorrectPathPolicy, + ReleaseWriterLock, + RepairSchema, + RestoreVerifiedState, + FreeStateDiskSpace, + RestoreIdentityBinding, + RepairSignerProvider, + CorrectAdminBindPolicy, + CorrectOperationsBindPolicy, + CorrectNetworkPolicy, + RestoreRequiredRelays, + RepairOutboxState, + CorrectClock, +} + +impl MycDoctorRemediationCode { + const fn as_str(self) -> &'static str { + match self { + Self::CorrectPathPolicy => "correct_path_policy", + Self::ReleaseWriterLock => "release_writer_lock", + Self::RepairSchema => "repair_schema", + Self::RestoreVerifiedState => "restore_verified_state", + Self::FreeStateDiskSpace => "free_state_disk_space", + Self::RestoreIdentityBinding => "restore_identity_binding", + Self::RepairSignerProvider => "repair_signer_provider", + Self::CorrectAdminBindPolicy => "correct_admin_bind_policy", + Self::CorrectOperationsBindPolicy => "correct_operations_bind_policy", + Self::CorrectNetworkPolicy => "correct_network_policy", + Self::RestoreRequiredRelays => "restore_required_relays", + Self::RepairOutboxState => "repair_outbox_state", + Self::CorrectClock => "correct_clock", + } + } +} + +/// Immutable authority for one check's requirement, deadline, and remediation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MycDoctorCheckDefinition { + id: MycDoctorCheckId, + required: bool, + deadline_ms: u64, + remediation_code: MycDoctorRemediationCode, + scope: &'static [&'static str], +} + +impl MycDoctorCheckDefinition { + const fn new( + id: MycDoctorCheckId, + required: bool, + deadline_ms: u64, + remediation_code: MycDoctorRemediationCode, + scope: &'static [&'static str], + ) -> Self { + Self { + id, + required, + deadline_ms, + remediation_code, + scope, + } + } + + /// Returns the governed check identifier. + #[must_use] + pub const fn id(self) -> MycDoctorCheckId { + self.id + } + + /// Returns whether a non-pass result fails the doctor command. + #[must_use] + pub const fn required(self) -> bool { + self.required + } + + /// Returns the exact per-check deadline in milliseconds. + #[must_use] + pub const fn deadline_ms(self) -> u64 { + self.deadline_ms + } + + /// Returns the fixed, safe operator remediation classification. + #[must_use] + pub const fn remediation_code(self) -> MycDoctorRemediationCode { + self.remediation_code + } + + /// Returns the exact safe evidence facets owned by this check. + #[must_use] + pub const fn scope(self) -> &'static [&'static str] { + self.scope + } +} + +const CHECK_DEFINITIONS: [MycDoctorCheckDefinition; MYC_DOCTOR_CHECK_COUNT] = [ + MycDoctorCheckDefinition::new( + MycDoctorCheckId::PathsPermissions, + true, + 2_000, + MycDoctorRemediationCode::CorrectPathPolicy, + &["resolved_path_containment", "owner", "type", "mode"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::WriterLock, + true, + 2_000, + MycDoctorRemediationCode::ReleaseWriterLock, + &["state_directory_binding", "writer_lock_state"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::SqliteSchema, + true, + 5_000, + MycDoctorRemediationCode::RepairSchema, + &["metadata_identity", "migration_history", "schema_catalog"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::SqliteIntegrity, + true, + 15_000, + MycDoctorRemediationCode::RestoreVerifiedState, + &["integrity_check", "foreign_key_check"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::SqliteFreeSpace, + true, + 2_000, + MycDoctorRemediationCode::FreeStateDiskSpace, + &["state_filesystem_capacity", "minimum_free_bytes"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::IdentityBinding, + true, + 2_000, + MycDoctorRemediationCode::RestoreIdentityBinding, + &[ + "envelope_contract", + "credential_reference", + "public_identity", + ], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::SignerProvider, + true, + 15_000, + MycDoctorRemediationCode::RepairSignerProvider, + &[ + "capability", + "contract_version", + "identity", + "correlation", + "deadline", + ], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::AdminBindPolicy, + true, + 2_000, + MycDoctorRemediationCode::CorrectAdminBindPolicy, + &["unix_socket_path", "socket_mode", "peer_authorization"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::OperationsBindPolicy, + true, + 2_000, + MycDoctorRemediationCode::CorrectOperationsBindPolicy, + &["enabled_posture", "listen_address", "bind_policy"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::NetworkPolicy, + true, + 2_000, + MycDoctorRemediationCode::CorrectNetworkPolicy, + &["dns_policy", "tls_policy", "relay_url_policy"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::RequiredRelays, + true, + 15_000, + MycDoctorRemediationCode::RestoreRequiredRelays, + &[ + "required_read_relays", + "required_write_relays", + "connect_deadline", + ], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::OutboxInvariants, + true, + 5_000, + MycDoctorRemediationCode::RepairOutboxState, + &["claim_invariants", "retry_state", "exact_response_bytes"], + ), + MycDoctorCheckDefinition::new( + MycDoctorCheckId::ClockSkew, + false, + 5_000, + MycDoctorRemediationCode::CorrectClock, + &["wall_clock_skew"], + ), +]; + +/// Returns the exact ordered doctor inventory. +#[must_use] +pub const fn myc_doctor_check_definitions() +-> &'static [MycDoctorCheckDefinition; MYC_DOCTOR_CHECK_COUNT] { + &CHECK_DEFINITIONS +} + +/// A closed result supplied by one bounded check implementation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycDoctorObservation { + Pass, + Fail, + Skipped, +} + +/// Future returned by one doctor probe. +pub type MycDoctorFuture<'a> = Pin<Box<dyn Future<Output = MycDoctorObservation> + Send + 'a>>; + +/// Executes each active check without receiving report-construction authority. +/// +/// `Pass` is permitted only after every facet in [`MycDoctorCheckDefinition::scope`] +/// is proven. Implementations must be cancellation-safe: the returned future +/// owns its work, and dropping it at the deadline must not leave detached work +/// or mutation running. +pub trait MycDoctorProbe: Send + Sync { + /// Runs one exact check. Raw errors, paths, and arbitrary summaries cannot + /// cross this boundary. + fn probe(&self, definition: MycDoctorCheckDefinition) -> MycDoctorFuture<'_>; +} + +/// Stable status of one completed check. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycDoctorCheckStatus { + Pass, + Fail, + Timeout, + Skipped, +} + +impl MycDoctorCheckStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Pass => "pass", + Self::Fail => "fail", + Self::Timeout => "timeout", + Self::Skipped => "skipped", + } + } + + const fn summary(self) -> &'static str { + match self { + Self::Pass => "check passed", + Self::Fail => "check failed", + Self::Timeout => "check timed out", + Self::Skipped => "optional check skipped", + } + } +} + +/// Stable aggregate doctor status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycDoctorAggregateStatus { + Pass, + Degraded, + Fail, +} + +impl MycDoctorAggregateStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Pass => "pass", + Self::Degraded => "degraded", + Self::Fail => "fail", + } + } +} + +/// One sealed structured doctor result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MycDoctorCheckResult { + definition: MycDoctorCheckDefinition, + status: MycDoctorCheckStatus, +} + +impl MycDoctorCheckResult { + /// Returns the exact check definition. + #[must_use] + pub const fn definition(self) -> MycDoctorCheckDefinition { + self.definition + } + + /// Returns the admitted check status. + #[must_use] + pub const fn status(self) -> MycDoctorCheckStatus { + self.status + } + + /// Returns the fixed content-free summary. + #[must_use] + pub const fn summary(self) -> &'static str { + self.status.summary() + } +} + +/// Stable source-free doctor construction failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycDoctorErrorKind { + Encoding, + OutputTooLarge, +} + +impl MycDoctorErrorKind { + const fn message(self) -> &'static str { + match self { + Self::Encoding => "Myc doctor output encoding failed", + Self::OutputTooLarge => "Myc doctor output exceeds its byte limit", + } + } +} + +/// One redacted doctor construction failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycDoctorError { + kind: MycDoctorErrorKind, +} + +impl MycDoctorError { + const fn new(kind: MycDoctorErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable error classification. + #[must_use] + pub const fn kind(self) -> MycDoctorErrorKind { + self.kind + } +} + +impl fmt::Debug for MycDoctorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycDoctorError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycDoctorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycDoctorError {} + +/// One immutable, bounded, canonical Myc doctor report. +/// +/// Construction remains inside [`run_myc_doctor`]: +/// +/// ```compile_fail +/// use myc::{MycDoctorAggregateStatus, MycDoctorReport}; +/// +/// let _ = MycDoctorReport { +/// instance: todo!(), +/// status: MycDoctorAggregateStatus::Pass, +/// checks: Box::new([]), +/// canonical_json: Box::new([]), +/// }; +/// ``` +pub struct MycDoctorReport { + instance: InstanceId, + status: MycDoctorAggregateStatus, + checks: Box<[MycDoctorCheckResult]>, + canonical_json: Box<[u8]>, +} + +impl MycDoctorReport { + /// Returns the fixed service identifier. + #[must_use] + pub const fn service(&self) -> &'static str { + MYC_SERVICE + } + + /// Returns the validated instance identifier admitted into the report. + #[must_use] + pub const fn instance(&self) -> &InstanceId { + &self.instance + } + + /// Returns the aggregate result. + #[must_use] + pub const fn status(&self) -> MycDoctorAggregateStatus { + self.status + } + + /// Returns the ordered complete check inventory. + #[must_use] + pub fn checks(&self) -> &[MycDoctorCheckResult] { + &self.checks + } + + /// Returns exact compact UTF-8 JSON in the shared v1 field order. + #[must_use] + pub fn canonical_json(&self) -> &[u8] { + &self.canonical_json + } + + /// Returns exit 6 only when a required check failed or timed out. + #[must_use] + pub const fn exit_code(&self) -> u8 { + match self.status { + MycDoctorAggregateStatus::Fail => DOCTOR_FAILURE_EXIT_CODE, + MycDoctorAggregateStatus::Pass | MycDoctorAggregateStatus::Degraded => 0, + } + } +} + +impl fmt::Debug for MycDoctorReport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycDoctorReport") + .field("service", &MYC_SERVICE) + .field("instance", &"[redacted]") + .field("status", &self.status) + .field("check_count", &self.checks.len()) + .field("canonical_json", &"[redacted]") + .finish() + } +} + +/// Runs every governed check in exact contract order under its fixed deadline. +/// +/// Probe implementations retain operation-specific filesystem, SQLite, +/// provider, listener, network, relay, and clock authority. This orchestrator +/// accepts only a closed result and cannot serialize their paths or raw errors. +pub async fn run_myc_doctor( + context: &MycRuntimeContext, + probe: &(impl MycDoctorProbe + ?Sized), +) -> Result<MycDoctorReport, MycDoctorError> { + let mut checks = Vec::with_capacity(MYC_DOCTOR_CHECK_COUNT); + for definition in CHECK_DEFINITIONS { + let status = match tokio::time::timeout( + Duration::from_millis(definition.deadline_ms), + probe.probe(definition), + ) + .await + { + Ok(MycDoctorObservation::Pass) => MycDoctorCheckStatus::Pass, + Ok(MycDoctorObservation::Fail) => MycDoctorCheckStatus::Fail, + Ok(MycDoctorObservation::Skipped) if !definition.required => { + MycDoctorCheckStatus::Skipped + } + Ok(MycDoctorObservation::Skipped) => MycDoctorCheckStatus::Fail, + Err(_) => MycDoctorCheckStatus::Timeout, + }; + checks.push(MycDoctorCheckResult { definition, status }); + } + let checks = checks.into_boxed_slice(); + let status = aggregate_status(&checks); + let instance = context.context().instance().clone(); + let canonical_json = encode_report(&instance, status, &checks)?; + + Ok(MycDoctorReport { + instance, + status, + checks, + canonical_json, + }) +} + +fn aggregate_status(checks: &[MycDoctorCheckResult]) -> MycDoctorAggregateStatus { + if checks + .iter() + .any(|result| result.definition.required && result.status != MycDoctorCheckStatus::Pass) + { + MycDoctorAggregateStatus::Fail + } else if checks + .iter() + .any(|result| result.status != MycDoctorCheckStatus::Pass) + { + MycDoctorAggregateStatus::Degraded + } else { + MycDoctorAggregateStatus::Pass + } +} + +#[derive(Serialize)] +struct DoctorWireReport<'a> { + contract_version: u32, + service: &'static str, + instance: &'a str, + status: &'static str, + checks: Vec<DoctorWireCheck>, +} + +#[derive(Serialize)] +struct DoctorWireCheck { + id: &'static str, + status: &'static str, + required: bool, + deadline_ms: u64, + summary: &'static str, + remediation_code: &'static str, +} + +fn encode_report( + instance: &InstanceId, + status: MycDoctorAggregateStatus, + checks: &[MycDoctorCheckResult], +) -> Result<Box<[u8]>, MycDoctorError> { + let checks = checks + .iter() + .map(|result| DoctorWireCheck { + id: result.definition.id.as_str(), + status: result.status.as_str(), + required: result.definition.required, + deadline_ms: result.definition.deadline_ms, + summary: result.status.summary(), + remediation_code: result.definition.remediation_code.as_str(), + }) + .collect(); + let encoded = serde_json::to_vec(&DoctorWireReport { + contract_version: MYC_DOCTOR_CONTRACT_VERSION, + service: MYC_SERVICE, + instance: instance.as_str(), + status: status.as_str(), + checks, + }) + .map_err(|_| MycDoctorError::new(MycDoctorErrorKind::Encoding))?; + if encoded.len() > MYC_DOCTOR_REPORT_MAX_UTF8_BYTES { + return Err(MycDoctorError::new(MycDoctorErrorKind::OutputTooLarge)); + } + Ok(encoded.into_boxed_slice()) +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use super::{MycDoctorError, MycDoctorErrorKind}; + + #[test] + fn errors_are_source_free_and_content_free() { + for kind in [ + MycDoctorErrorKind::Encoding, + MycDoctorErrorKind::OutputTooLarge, + ] { + let error = MycDoctorError::new(kind); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + for forbidden in ["/private", "secret", "relay", "sqlite"] { + assert!(!rendered.to_ascii_lowercase().contains(forbidden)); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -5,6 +5,7 @@ mod admin_v1; mod cli_v1; mod config_v1; +mod doctor_v1; mod nip46_admission; mod nip46_authorization; mod nip46_replay; @@ -53,6 +54,13 @@ pub use config_v1::{ MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind, MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1, }; +pub use doctor_v1::{ + MYC_DOCTOR_CHECK_COUNT, MYC_DOCTOR_CONTRACT_VERSION, MYC_DOCTOR_REPORT_MAX_UTF8_BYTES, + MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, MycDoctorAggregateStatus, MycDoctorCheckDefinition, + MycDoctorCheckId, MycDoctorCheckResult, MycDoctorCheckStatus, MycDoctorError, + MycDoctorErrorKind, MycDoctorFuture, MycDoctorObservation, MycDoctorProbe, + MycDoctorRemediationCode, MycDoctorReport, myc_doctor_check_definitions, run_myc_doctor, +}; pub use nip46_admission::{ MYC_NIP46_EVENT_ID_MAX_BYTES, MYC_NIP46_PUBLIC_KEY_MAX_BYTES, MYC_NIP46_SIGNATURE_MAX_BYTES, MycBoundedNip46Event, MycBoundedNip46Request, MycNip46AdmissionError, diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -14,6 +14,7 @@ const NIP46_WAVE_080_A: &str = include_str!("../src/nip46_wave_080_a.rs"); const NIP46_COMPLETION: &str = include_str!("../src/state_completion.rs"); const NIP46_RESPONSE: &str = include_str!("../src/state_response.rs"); const DELIVERY_RECOVERY: &str = include_str!("../src/state_recovery.rs"); +const DOCTOR_V1: &str = include_str!("../src/doctor_v1.rs"); const DISCOVERY_STATE: &str = include_str!("../src/state_discovery.rs"); const NIP46_VERIFICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_verification.v1.json"); @@ -35,6 +36,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/admin_v1.rs"), include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), + include_str!("../src/doctor_v1.rs"), include_str!("../src/nip46_admission.rs"), include_str!("../src/nip46_authorization.rs"), include_str!("../src/nip46_replay.rs"), @@ -73,6 +75,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "admin_v1", "cli_v1", "config_v1", + "doctor_v1", "nip46_admission", "nip46_authorization", "nip46_replay", @@ -124,6 +127,16 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub enum myc::MycCliOfflineOperationV1", "pub enum myc::MycCliAdminOperationV1", "pub const fn myc::plan_myc_cli_v1", + "pub struct myc::MycDoctorReport", + "pub struct myc::MycDoctorCheckDefinition", + "pub struct myc::MycDoctorCheckResult", + "pub enum myc::MycDoctorCheckId", + "pub enum myc::MycDoctorCheckStatus", + "pub enum myc::MycDoctorAggregateStatus", + "pub enum myc::MycDoctorObservation", + "pub enum myc::MycDoctorRemediationCode", + "pub trait myc::MycDoctorProbe", + "pub async fn myc::run_myc_doctor", "pub struct myc::MycAdminRequestDocument", "pub struct myc::MycAdminResponseDocument", "pub enum myc::MycAdminMethod", @@ -196,6 +209,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "admin_v1", "cli_v1", "config_v1", + "doctor_v1", "nip46_admission", "nip46_authorization", "nip46_replay", @@ -238,6 +252,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "sqlx::", "serde::", "serde_json::", + "futures_util::", "toml::", "url::", "nostr::", @@ -256,6 +271,32 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { } #[test] +fn doctor_boundary_is_closed_bounded_and_dependency_neutral() { + for required in [ + "MYC_DOCTOR_CHECK_COUNT: usize = 13", + "MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize = 256", + "MYC_DOCTOR_REPORT_MAX_UTF8_BYTES: usize = 8_192", + "for definition in CHECK_DEFINITIONS", + "tokio::time::timeout(", + "MycDoctorObservation::Skipped) if !definition.required", + "MycDoctorObservation::Skipped) => MycDoctorCheckStatus::Fail", + ] { + assert!(DOCTOR_V1.contains(required), "missing `{required}`"); + } + for forbidden in [ + "std::fs::", + "sqlx::", + "reqwest::", + "url::Url", + "std::env::", + "raw_error", + "PathBuf", + ] { + assert!(!DOCTOR_V1.contains(forbidden), "found `{forbidden}`"); + } +} + +#[test] fn step148_response_commit_is_one_atomic_exact_byte_authority() { let contract: serde_json::Value = serde_json::from_str(NIP46_RESPONSE_CONTRACT).expect("Step 148 contract"); @@ -601,7 +642,8 @@ fn public_errors_remain_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct myc::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 27); + assert_eq!(public_error_count, 28); + assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError")); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } diff --git a/tests/services_hardening_contracts.rs b/tests/services_hardening_contracts.rs @@ -340,22 +340,42 @@ fn doctor_exit_and_tcp_contracts_are_exact() { "radroots.service.doctor.v1" ); assert_eq!(value["doctor"]["contract_version"], 1); + assert_eq!(value["doctor"]["execution"], "ordered"); + assert_eq!(value["doctor"]["pass_requires_all_scope"], true); + assert_eq!( + value["doctor"]["probe_future_cancellation"], + "drop_stops_or_owns_cleanup" + ); + assert_eq!(value["doctor"]["detached_probe_work"], false); + assert_eq!( + value["doctor"]["statuses"], + serde_json::json!(["pass", "fail", "timeout", "skipped"]) + ); + assert_eq!( + value["doctor"]["aggregate_statuses"], + serde_json::json!(["pass", "degraded", "fail"]) + ); + assert_eq!(value["doctor"]["required_skipped"], "forbidden"); + assert_eq!(value["doctor"]["summary_max_utf8_bytes"], 256); + assert_eq!(value["doctor"]["report_max_utf8_bytes"], 8192); + assert_eq!(value["doctor"]["raw_error_or_path_allowed"], false); + assert_eq!(value["doctor"]["required_fail_or_timeout_exit"], 6); assert_eq!( value["doctor"]["checks"], serde_json::json!([ - { "id": "paths_permissions", "required": true }, - { "id": "writer_lock", "required": true }, - { "id": "sqlite_schema", "required": true }, - { "id": "sqlite_integrity", "required": true }, - { "id": "sqlite_free_space", "required": true }, - { "id": "identity_binding", "required": true }, - { "id": "signer_provider", "required": true }, - { "id": "admin_bind_policy", "required": true }, - { "id": "operations_bind_policy", "required": true }, - { "id": "network_policy", "required": true }, - { "id": "required_relays", "required": true }, - { "id": "outbox_invariants", "required": true }, - { "id": "clock_skew", "required": false } + { "id": "paths_permissions", "required": true, "deadline_ms": 2000, "remediation_code": "correct_path_policy", "scope": ["resolved_path_containment", "owner", "type", "mode"] }, + { "id": "writer_lock", "required": true, "deadline_ms": 2000, "remediation_code": "release_writer_lock", "scope": ["state_directory_binding", "writer_lock_state"] }, + { "id": "sqlite_schema", "required": true, "deadline_ms": 5000, "remediation_code": "repair_schema", "scope": ["metadata_identity", "migration_history", "schema_catalog"] }, + { "id": "sqlite_integrity", "required": true, "deadline_ms": 15000, "remediation_code": "restore_verified_state", "scope": ["integrity_check", "foreign_key_check"] }, + { "id": "sqlite_free_space", "required": true, "deadline_ms": 2000, "remediation_code": "free_state_disk_space", "scope": ["state_filesystem_capacity", "minimum_free_bytes"] }, + { "id": "identity_binding", "required": true, "deadline_ms": 2000, "remediation_code": "restore_identity_binding", "scope": ["envelope_contract", "credential_reference", "public_identity"] }, + { "id": "signer_provider", "required": true, "deadline_ms": 15000, "remediation_code": "repair_signer_provider", "scope": ["capability", "contract_version", "identity", "correlation", "deadline"] }, + { "id": "admin_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_admin_bind_policy", "scope": ["unix_socket_path", "socket_mode", "peer_authorization"] }, + { "id": "operations_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_operations_bind_policy", "scope": ["enabled_posture", "listen_address", "bind_policy"] }, + { "id": "network_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_network_policy", "scope": ["dns_policy", "tls_policy", "relay_url_policy"] }, + { "id": "required_relays", "required": true, "deadline_ms": 15000, "remediation_code": "restore_required_relays", "scope": ["required_read_relays", "required_write_relays", "connect_deadline"] }, + { "id": "outbox_invariants", "required": true, "deadline_ms": 5000, "remediation_code": "repair_outbox_state", "scope": ["claim_invariants", "retry_state", "exact_response_bytes"] }, + { "id": "clock_skew", "required": false, "deadline_ms": 5000, "remediation_code": "correct_clock", "scope": ["wall_clock_skew"] } ]) ); assert_eq!( diff --git a/tests/services_hardening_doctor.rs b/tests/services_hardening_doctor.rs @@ -0,0 +1,297 @@ +#![forbid(unsafe_code)] + +use std::{ + collections::BTreeMap, + future::pending, + sync::{Arc, Mutex}, +}; + +use myc::{ + MYC_DOCTOR_CHECK_COUNT, MYC_DOCTOR_CONTRACT_VERSION, MYC_DOCTOR_REPORT_MAX_UTF8_BYTES, + MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, MycDoctorAggregateStatus, MycDoctorCheckDefinition, + MycDoctorCheckId, MycDoctorCheckStatus, MycDoctorFuture, MycDoctorObservation, MycDoctorProbe, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, myc_doctor_check_definitions, + parse_myc_cli_v1_from, resolve_myc_runtime_context, run_myc_doctor, +}; +use sha2::{Digest, Sha256}; + +const OPERATOR_CONTRACT: &str = + include_str!("../contracts/services_hardening/operator_contract.v1.json"); + +struct TestProbe { + outcomes: BTreeMap<MycDoctorCheckId, MycDoctorObservation>, + pending: Option<MycDoctorCheckId>, + calls: Arc<Mutex<Vec<MycDoctorCheckId>>>, +} + +impl TestProbe { + fn all(outcome: MycDoctorObservation) -> Self { + Self { + outcomes: myc_doctor_check_definitions() + .iter() + .map(|definition| (definition.id(), outcome)) + .collect(), + pending: None, + calls: Arc::new(Mutex::new(Vec::new())), + } + } + + fn with(mut self, id: MycDoctorCheckId, outcome: MycDoctorObservation) -> Self { + self.outcomes.insert(id, outcome); + self + } + + fn pending(mut self, id: MycDoctorCheckId) -> Self { + self.pending = Some(id); + self + } +} + +impl MycDoctorProbe for TestProbe { + fn probe(&self, definition: MycDoctorCheckDefinition) -> MycDoctorFuture<'_> { + let id = definition.id(); + self.calls.lock().expect("calls lock").push(id); + if self.pending == Some(id) { + return Box::pin(pending()); + } + let outcome = self.outcomes[&id]; + Box::pin(async move { outcome }) + } +} + +fn runtime() -> (tempfile::TempDir, myc::MycRuntimeContext) { + let directory = tempfile::tempdir().expect("temporary root"); + let root = directory.path().to_str().expect("UTF-8 path"); + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root, + "doctor", + ]) + .expect("doctor invocation"); + let context = resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context"); + (directory, context) +} + +#[test] +fn exact_inventory_matches_the_operator_contract() { + let contract: serde_json::Value = serde_json::from_str(OPERATOR_CONTRACT).expect("contract"); + let doctor = contract["doctor"].as_object().expect("doctor"); + assert_eq!( + doctor + .keys() + .map(String::as_str) + .collect::<std::collections::BTreeSet<_>>(), + std::collections::BTreeSet::from([ + "aggregate_statuses", + "checks", + "contract_version", + "detached_probe_work", + "execution", + "pass_requires_all_scope", + "probe_future_cancellation", + "raw_error_or_path_allowed", + "report_max_utf8_bytes", + "required_fail_or_timeout_exit", + "required_skipped", + "shared_schema", + "statuses", + "summary_max_utf8_bytes", + ]) + ); + assert_eq!(MYC_DOCTOR_CONTRACT_VERSION, 1); + assert_eq!(MYC_DOCTOR_CHECK_COUNT, 13); + assert_eq!(MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, 256); + assert_eq!(MYC_DOCTOR_REPORT_MAX_UTF8_BYTES, 8_192); + assert_eq!(doctor["execution"], "ordered"); + assert_eq!(doctor["pass_requires_all_scope"], true); + assert_eq!( + doctor["probe_future_cancellation"], + "drop_stops_or_owns_cleanup" + ); + assert_eq!(doctor["detached_probe_work"], false); + assert_eq!(doctor["required_skipped"], "forbidden"); + assert_eq!(doctor["raw_error_or_path_allowed"], false); + assert_eq!(doctor["required_fail_or_timeout_exit"], 6); + + let rows = doctor["checks"].as_array().expect("checks"); + assert_eq!(rows.len(), MYC_DOCTOR_CHECK_COUNT); + for (definition, row) in myc_doctor_check_definitions().iter().zip(rows) { + assert_eq!(row["id"], id_name(definition.id())); + assert_eq!(row["required"], definition.required()); + assert_eq!(row["deadline_ms"], definition.deadline_ms()); + assert_eq!( + row["remediation_code"], + remediation_name(definition.remediation_code()) + ); + assert_eq!( + row["scope"], + serde_json::to_value(definition.scope()).expect("scope") + ); + } +} + +#[tokio::test] +async fn all_pass_is_canonical_bounded_and_exit_zero() { + let (_directory, context) = runtime(); + let probe = TestProbe::all(MycDoctorObservation::Pass); + let report = run_myc_doctor(&context, &probe).await.expect("report"); + assert_eq!(report.service(), "myc"); + assert_eq!(report.instance().as_str(), "primary"); + assert_eq!(report.status(), MycDoctorAggregateStatus::Pass); + assert_eq!(report.exit_code(), 0); + assert_eq!(report.checks().len(), MYC_DOCTOR_CHECK_COUNT); + assert!( + report + .checks() + .iter() + .all(|result| result.status() == MycDoctorCheckStatus::Pass) + ); + assert_eq!( + probe.calls.lock().expect("calls").len(), + MYC_DOCTOR_CHECK_COUNT + ); + + let bytes = report.canonical_json(); + assert!(bytes.len() <= MYC_DOCTOR_REPORT_MAX_UTF8_BYTES); + assert!(!bytes.contains(&b'\n')); + let wire: serde_json::Value = serde_json::from_slice(bytes).expect("JSON"); + assert_eq!(wire["contract_version"], 1); + assert_eq!(wire["service"], "myc"); + assert_eq!(wire["instance"], "primary"); + assert_eq!(wire["status"], "pass"); + assert_eq!(wire["checks"].as_array().expect("checks").len(), 13); + assert!(String::from_utf8_lossy(bytes).starts_with( + "{\"contract_version\":1,\"service\":\"myc\",\"instance\":\"primary\",\"status\":\"pass\",\"checks\":[" + )); + assert_eq!( + hex::encode(Sha256::digest(bytes)), + "19d7b33a205ed26fa6cf8c0c77ada75cdea7a1e01bca45efa72f95c65ac645ce" + ); +} + +#[tokio::test] +async fn optional_nonpass_is_degraded_but_successful() { + let (_directory, context) = runtime(); + for outcome in [MycDoctorObservation::Fail, MycDoctorObservation::Skipped] { + let probe = + TestProbe::all(MycDoctorObservation::Pass).with(MycDoctorCheckId::ClockSkew, outcome); + let report = run_myc_doctor(&context, &probe).await.expect("report"); + assert_eq!(report.status(), MycDoctorAggregateStatus::Degraded); + assert_eq!(report.exit_code(), 0); + assert_ne!(report.checks()[12].status(), MycDoctorCheckStatus::Pass); + } +} + +#[tokio::test] +async fn required_fail_and_skip_are_fail_closed() { + let (_directory, context) = runtime(); + for outcome in [MycDoctorObservation::Fail, MycDoctorObservation::Skipped] { + let probe = + TestProbe::all(MycDoctorObservation::Pass).with(MycDoctorCheckId::WriterLock, outcome); + let report = run_myc_doctor(&context, &probe).await.expect("report"); + assert_eq!(report.status(), MycDoctorAggregateStatus::Fail); + assert_eq!(report.exit_code(), 6); + assert_eq!(report.checks()[1].status(), MycDoctorCheckStatus::Fail); + } +} + +#[tokio::test] +async fn required_timeout_is_bounded_and_remaining_checks_continue_in_order() { + let (_directory, context) = runtime(); + let probe = + TestProbe::all(MycDoctorObservation::Pass).pending(MycDoctorCheckId::PathsPermissions); + let report = run_myc_doctor(&context, &probe).await.expect("report"); + assert_eq!(report.status(), MycDoctorAggregateStatus::Fail); + assert_eq!(report.exit_code(), 6); + assert_eq!(report.checks()[0].status(), MycDoctorCheckStatus::Timeout); + let calls = probe.calls.lock().expect("calls").clone(); + assert_eq!( + calls, + myc_doctor_check_definitions() + .iter() + .map(|definition| definition.id()) + .collect::<Vec<_>>() + ); +} + +#[tokio::test] +async fn report_debug_and_error_surface_retain_no_sensitive_values() { + let directory = tempfile::tempdir().expect("temporary root"); + let root = directory.path().join("secret-root"); + let root = root.to_str().expect("UTF-8 path"); + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + "secret-instance", + "--repo-local-root", + root, + "doctor", + ]) + .expect("doctor invocation"); + let context = resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context"); + let report = run_myc_doctor(&context, &TestProbe::all(MycDoctorObservation::Pass)) + .await + .expect("report"); + let debug = format!("{report:?}"); + assert!(!debug.contains("secret-instance")); + assert!(!debug.contains("secret-root")); + for result in report.checks() { + assert!(result.summary().len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES); + } + + let rendered = format!("{:?}", myc::MycDoctorErrorKind::OutputTooLarge); + assert!(!rendered.contains("secret")); +} + +fn id_name(id: MycDoctorCheckId) -> &'static str { + match id { + MycDoctorCheckId::PathsPermissions => "paths_permissions", + MycDoctorCheckId::WriterLock => "writer_lock", + MycDoctorCheckId::SqliteSchema => "sqlite_schema", + MycDoctorCheckId::SqliteIntegrity => "sqlite_integrity", + MycDoctorCheckId::SqliteFreeSpace => "sqlite_free_space", + MycDoctorCheckId::IdentityBinding => "identity_binding", + MycDoctorCheckId::SignerProvider => "signer_provider", + MycDoctorCheckId::AdminBindPolicy => "admin_bind_policy", + MycDoctorCheckId::OperationsBindPolicy => "operations_bind_policy", + MycDoctorCheckId::NetworkPolicy => "network_policy", + MycDoctorCheckId::RequiredRelays => "required_relays", + MycDoctorCheckId::OutboxInvariants => "outbox_invariants", + MycDoctorCheckId::ClockSkew => "clock_skew", + } +} + +fn remediation_name(code: myc::MycDoctorRemediationCode) -> &'static str { + match code { + myc::MycDoctorRemediationCode::CorrectPathPolicy => "correct_path_policy", + myc::MycDoctorRemediationCode::ReleaseWriterLock => "release_writer_lock", + myc::MycDoctorRemediationCode::RepairSchema => "repair_schema", + myc::MycDoctorRemediationCode::RestoreVerifiedState => "restore_verified_state", + myc::MycDoctorRemediationCode::FreeStateDiskSpace => "free_state_disk_space", + myc::MycDoctorRemediationCode::RestoreIdentityBinding => "restore_identity_binding", + myc::MycDoctorRemediationCode::RepairSignerProvider => "repair_signer_provider", + myc::MycDoctorRemediationCode::CorrectAdminBindPolicy => "correct_admin_bind_policy", + myc::MycDoctorRemediationCode::CorrectOperationsBindPolicy => { + "correct_operations_bind_policy" + } + myc::MycDoctorRemediationCode::CorrectNetworkPolicy => "correct_network_policy", + myc::MycDoctorRemediationCode::RestoreRequiredRelays => "restore_required_relays", + myc::MycDoctorRemediationCode::RepairOutboxState => "repair_outbox_state", + myc::MycDoctorRemediationCode::CorrectClock => "correct_clock", + } +}