commit 89285d7a1f3c045fc952ac2caadd303dd646254b
parent eea96175b40fd85d9a2463aec44af243f6492ca6
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 13:38:54 +0000
cli: execute secure offline commands
Diffstat:
30 files changed, 3772 insertions(+), 236 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -174,6 +174,16 @@
secure CLI/config bootstrap and concrete doctor probes; Unit 15 alone owns
server task spawning, provider/relay wiring, readiness, reconnect, and
shutdown.
+- Step 159 unit 14 owns the one-pass process executor, descriptor-bound config
+ loading and create-new persistence, fixed zeroizing identity-provisioning
+ input, actual existing-state metadata discovery, explicit backup/restore
+ inputs, one binary-owned configured Tokio runtime, and concrete bounded
+ doctor probes. Keep result bytes on stdout and fixed diagnostics on stderr.
+ The executable must not provision deployment directory trees, derive runtime
+ limits from host CPUs, read secret arguments or environment variables, open
+ an existing live database before validating a restore manifest, publish from
+ doctor, or return success for the deferred daemon `run` graph. Unit 15 alone
+ owns that graph, process signals, readiness/reconnect, and phase-aware drain.
- 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
@@ -59,13 +59,36 @@ requires explicit `--profile <service-host|interactive|repo-local>` and
`--instance <validated-instance-id>` selectors, requires an absolute
`--repo-local-root` only for `repo-local`, and accepts only an optional absolute
`--config` path. Its exact command inventory is `run`; `config
-init|validate|show|schema`; `state init|status|backup|restore|verify|migrate`;
+init|validate|show|schema|apply`; `state init|status|backup|restore|verify|migrate`;
`identity init|status|export-public`; `status`; and `doctor`. Identity rotation
is an offline create-new/config-apply lifecycle and is intentionally absent
from the live CLI and Unix-admin inventories.
-The process binary uses only this parser. Hardened command execution remains
-fail-closed until its ordered runtime-dispatch steps are complete; no prototype
-command is used as a fallback.
+The process binary uses only this parser and consumes its sealed execution plan
+exactly once. Every non-daemon command now reaches its governed config, state,
+identity, Unix-admin, or doctor authority. `run` remains explicitly unavailable
+until the next ordered unit installs the authoritative daemon graph; no admitted
+but unimplemented command can return success and no prototype command is used
+as a fallback.
+
+The selected absolute config path is opened no-follow through its retained
+parent descriptor. The loader requires a regular, single-link,
+effective-user-owned file with no group/other-write permission, caps bytes
+before parsing, and revalidates device, inode, length, and parent identity after
+the read. Config initialization consumes bounded non-secret TOML from stdin and
+uses create-new `0600` persistence plus file and parent synchronization. Secret
+identity provisioning is a separate fixed 117-byte zeroizing stdin document;
+secret arguments, environment values, JSON strings, and trailing bytes are not
+accepted.
+
+One binary-owned Tokio runtime is created from the validated fixed thread
+limits for each asynchronous command process; there is no CPU-derived default
+or library-owned runtime. Existing-state commands discover actual metadata
+through the retained shared intent. Offline backup writes the exact canonical
+manifest bytes to stdout without a trailing newline, so direct redirection
+preserves the bytes bound by its digest. Restore derives expected backup identity
+from the trusted manifest digest rather than requiring the damaged live
+database to open, and exclusive commands retain the writer-authority boundary.
+Deployment directory provisioning remains outside the service executable.
The parsed invocation is projected once into a sealed execution plan. `run`
selects daemon authority; config, exclusive state/identity provisioning, and
@@ -85,6 +108,11 @@ 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.
+The production adapter composes secure path and disk inspection, writer-lock
+and SQLite authorities, exact schema and outbox checks, provider opening and
+Describe verification, validated bind/network policy, and bounded required-
+relay reads. It never publishes a relay event. Clock skew remains the sole
+optional `Skipped` check until a trusted time source is governed.
The service status boundary uses the shared service-host lifecycle contract
behind one Myc-owned non-clone publisher and cloneable passive readers. Each
@@ -121,8 +149,8 @@ codes. It accepts no caller text, path, SQL, raw cause, identity, relay URL,
credential, secret, or decrypted content. Every public Myc error remains a
crate-owned source-free classification, so ordinary `Display`, `Debug`, and
whole-chain traversal cannot bypass redaction. Current binary dispatch reports
-invalid input as exit 2 and the intentionally unavailable later executor as
-exit 3. The Myc runtime now owns one sealed, bounded critical-task graph over
+invalid input as exit 2 and the intentionally unavailable daemon graph as exit
+3. The Myc runtime now owns one sealed, bounded critical-task graph over
the shared supervisor: task error, panic, join failure, unexpected cancellation,
or success before cancellation cancels peers, joins every task, and maps to the
fixed unexpected-internal process result. Tasks receive only a cooperative
@@ -156,8 +184,8 @@ ledger, retains exclusive writer authority, applies the exact Myc schema-v2
through schema-v11 migrations, binds the normalized configuration, expected
identity roles, and policy versions through a sealed typed repository, and
explicitly closes the host before reporting success. Existing writable open can
-resume any exact v1 through v9 prefix; read-only inspection requires the current
-catalog and exact latest Myc binding.
+resume any exact v1 through v10 prefix or admit the current v11 catalog;
+read-only inspection requires the current catalog and exact latest Myc binding.
Schema v10 adds an append-only configuration-binding history capped at exactly
1,024 generations. Generation 1 is seeded only after the v10 migration commits,
@@ -201,8 +229,9 @@ persists Submitted immediately before execution. Accepted, rejected,
transport-failed, and unknown acknowledgements remain distinct; cancellation
or lost acknowledgement after Submitted is durably unknown, and retries never
alter the committed bytes. Provider or relay work never occurs inside a SQLite
-transaction. Runtime task-graph wiring and startup handshakes remain the next
-ordered Step 159 unit.
+transaction. Unit 14's secure command executor uses these exact provider and
+read-only relay probe boundaries; runtime task-graph wiring and startup
+handshakes remain the next ordered Step 159 unit.
Schema v8 adds immutable NIP-46 operation-completion evidence. The Step 147
integration checkpoint binds each durable request to its stable operation and
@@ -260,10 +289,10 @@ canonical permissioned `admin.sock` through the shared host authority. The raw
router, listener, entropy source, and cancellation token remain private. The
existing sole status publisher feeds both detailed local status and the
optional passive three-route TCP operations server, while doctor continues to
-accept only its exact injected 13-check probe inventory. Unit 14 owns secure
-CLI/config bootstrap and the concrete doctor probe implementations; Unit 15
-alone owns task spawning, provider/relay wiring, readiness, reconnect, and
-phase-aware shutdown.
+accept only its exact injected 13-check probe inventory. Unit 14 supplies the
+secure CLI/config bootstrap and concrete doctor probes. Unit 15 alone owns task
+spawning, provider/relay wiring, readiness, reconnect, and phase-aware
+shutdown.
The transport capability and admitted request remain non-forgeable outside
the crate:
diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt
@@ -146,12 +146,16 @@ pub myc::MycCliOfflineOperationV1::IdentityExclusive
pub myc::MycCliOfflineOperationV1::IdentityReadOnly
pub myc::MycCliOfflineOperationV1::StateExclusive
pub myc::MycCliOfflineOperationV1::StateReadOnly
+pub enum myc::MycCliOutputModeV1
+pub myc::MycCliOutputModeV1::Human
+pub myc::MycCliOutputModeV1::Json
pub enum myc::MycCliPrimaryAuthorityV1
pub myc::MycCliPrimaryAuthorityV1::Daemon
pub myc::MycCliPrimaryAuthorityV1::LiveUnixAdmin
pub myc::MycCliPrimaryAuthorityV1::Offline
pub enum myc::MycCliV1ErrorKind
pub myc::MycCliV1ErrorKind::InvalidArguments
+pub myc::MycCliV1ErrorKind::InvalidCommandInput
pub myc::MycCliV1ErrorKind::InvalidConfigPath
pub myc::MycCliV1ErrorKind::InvalidInstance
pub myc::MycCliV1ErrorKind::InvalidRepoLocalRoot
@@ -163,6 +167,8 @@ pub myc::MycCommandV1::Identity(myc::MycIdentityCommandV1)
pub myc::MycCommandV1::Run
pub myc::MycCommandV1::State(myc::MycStateCommandV1)
pub myc::MycCommandV1::Status
+impl core::fmt::Debug for myc::MycCommandV1
+pub fn myc::MycCommandV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub enum myc::MycConfigApplyErrorKind
pub myc::MycConfigApplyErrorKind::Binding
pub myc::MycConfigApplyErrorKind::CommitOutcomeUnknown
@@ -174,10 +180,21 @@ pub myc::MycConfigApplyErrorKind::Transaction
impl myc::MycConfigApplyErrorKind
pub const fn myc::MycConfigApplyErrorKind::code(self) -> &'static str
pub enum myc::MycConfigCommandV1
+pub myc::MycConfigCommandV1::Apply(myc::MycConfigApplyArgsV1)
pub myc::MycConfigCommandV1::Init
pub myc::MycConfigCommandV1::Schema
pub myc::MycConfigCommandV1::Show
pub myc::MycConfigCommandV1::Validate
+pub enum myc::MycConfigLoadErrorKind
+pub myc::MycConfigLoadErrorKind::AlreadyExists
+pub myc::MycConfigLoadErrorKind::InsecureArtifact
+pub myc::MycConfigLoadErrorKind::InsecureParent
+pub myc::MycConfigLoadErrorKind::InvalidDocument
+pub myc::MycConfigLoadErrorKind::InvalidPath
+pub myc::MycConfigLoadErrorKind::Io
+pub myc::MycConfigLoadErrorKind::Missing
+pub myc::MycConfigLoadErrorKind::TooLarge
+pub myc::MycConfigLoadErrorKind::UnsupportedPlatform
pub enum myc::MycConfigProfile
pub myc::MycConfigProfile::Production
pub myc::MycConfigProfile::RepoLocal
@@ -409,9 +426,9 @@ pub myc::MycGovernanceStateErrorKind::InvalidRelayId
impl myc::MycGovernanceStateErrorKind
pub const fn myc::MycGovernanceStateErrorKind::code(self) -> &'static str
pub enum myc::MycIdentityCommandV1
-pub myc::MycIdentityCommandV1::ExportPublic
-pub myc::MycIdentityCommandV1::Init
-pub myc::MycIdentityCommandV1::Status
+pub myc::MycIdentityCommandV1::ExportPublic(myc::MycIdentityCommandArgsV1)
+pub myc::MycIdentityCommandV1::Init(myc::MycIdentityCommandArgsV1)
+pub myc::MycIdentityCommandV1::Status(myc::MycIdentityCommandArgsV1)
pub enum myc::MycIntegrityStateV1
pub myc::MycIntegrityStateV1::Failed
pub myc::MycIntegrityStateV1::VerificationRequired
@@ -718,10 +735,10 @@ pub myc::MycStateCatalogErrorKind::SchemaCatalog
impl myc::MycStateCatalogErrorKind
pub const fn myc::MycStateCatalogErrorKind::code(self) -> &'static str
pub enum myc::MycStateCommandV1
-pub myc::MycStateCommandV1::Backup
+pub myc::MycStateCommandV1::Backup(myc::MycStateBackupArgsV1)
pub myc::MycStateCommandV1::Init
pub myc::MycStateCommandV1::Migrate
-pub myc::MycStateCommandV1::Restore
+pub myc::MycStateCommandV1::Restore(myc::MycStateRestoreArgsV1)
pub myc::MycStateCommandV1::Status
pub myc::MycStateCommandV1::Verify
pub enum myc::MycStateHostErrorKind
@@ -984,9 +1001,10 @@ impl core::fmt::Debug for myc::MycCliExecutionPlanV1
pub fn myc::MycCliExecutionPlanV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycCliInvocationV1
impl myc::MycCliInvocationV1
-pub const fn myc::MycCliInvocationV1::command(&self) -> myc::MycCommandV1
+pub const fn myc::MycCliInvocationV1::command(&self) -> &myc::MycCommandV1
pub fn myc::MycCliInvocationV1::config_path(&self) -> core::option::Option<&std::path::Path>
pub fn myc::MycCliInvocationV1::instance(&self) -> &radroots_runtime_paths::identifier::InstanceId
+pub const fn myc::MycCliInvocationV1::output_mode(&self) -> myc::MycCliOutputModeV1
pub const fn myc::MycCliInvocationV1::profile(&self) -> myc::MycBootstrapProfileV1
pub fn myc::MycCliInvocationV1::repo_local_root(&self) -> core::option::Option<&std::path::Path>
impl core::fmt::Debug for myc::MycCliInvocationV1
@@ -999,6 +1017,11 @@ impl core::fmt::Debug for myc::MycCliV1Error
pub fn myc::MycCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for myc::MycCliV1Error
pub fn myc::MycCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycConfigApplyArgsV1
+impl myc::MycConfigApplyArgsV1
+pub fn myc::MycConfigApplyArgsV1::candidate_config(&self) -> &std::path::Path
+impl core::fmt::Debug for myc::MycConfigApplyArgsV1
+pub fn myc::MycConfigApplyArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycConfigApplyError
impl myc::MycConfigApplyError
pub const fn myc::MycConfigApplyError::code(self) -> &'static str
@@ -1021,10 +1044,19 @@ pub const fn myc::MycConfigDocumentV1::effective(&self) -> &myc::MycEffectiveCon
pub const fn myc::MycConfigDocumentV1::profile(&self) -> myc::MycConfigProfile
pub const fn myc::MycConfigDocumentV1::provider_contract(&self) -> &myc::MycProviderContract
pub fn myc::MycConfigDocumentV1::relay_count(&self) -> usize
+pub const fn myc::MycConfigDocumentV1::runtime_thread_limits(&self) -> myc::MycRuntimeThreadLimitsV1
pub const fn myc::MycConfigDocumentV1::schema(&self) -> &'static str
pub const fn myc::MycConfigDocumentV1::schema_version(&self) -> u32
impl core::fmt::Debug for myc::MycConfigDocumentV1
pub fn myc::MycConfigDocumentV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycConfigLoadError
+impl myc::MycConfigLoadError
+pub const fn myc::MycConfigLoadError::kind(self) -> myc::MycConfigLoadErrorKind
+impl core::error::Error for myc::MycConfigLoadError
+impl core::fmt::Debug for myc::MycConfigLoadError
+pub fn myc::MycConfigLoadError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for myc::MycConfigLoadError
+pub fn myc::MycConfigLoadError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycConfigV1Error
impl myc::MycConfigV1Error
pub const fn myc::MycConfigV1Error::kind(self) -> myc::MycConfigV1ErrorKind
@@ -1361,6 +1393,9 @@ impl core::fmt::Debug for myc::MycGovernanceStateError
pub fn myc::MycGovernanceStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for myc::MycGovernanceStateError
pub fn myc::MycGovernanceStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycIdentityCommandArgsV1
+impl myc::MycIdentityCommandArgsV1
+pub const fn myc::MycIdentityCommandArgsV1::role(self) -> myc::MycProviderRole
pub struct myc::MycIdentityHealthV1
impl myc::MycIdentityHealthV1
pub const fn myc::MycIdentityHealthV1::is_available(&self) -> bool
@@ -1825,6 +1860,10 @@ impl core::fmt::Debug for myc::MycRuntimeSupervisionError
pub fn myc::MycRuntimeSupervisionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for myc::MycRuntimeSupervisionError
pub fn myc::MycRuntimeSupervisionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycRuntimeThreadLimitsV1
+impl myc::MycRuntimeThreadLimitsV1
+pub const fn myc::MycRuntimeThreadLimitsV1::blocking_threads(self) -> usize
+pub const fn myc::MycRuntimeThreadLimitsV1::worker_threads(self) -> usize
pub struct myc::MycSignerCorrelationId(_)
impl myc::MycSignerCorrelationId
pub const fn myc::MycSignerCorrelationId::as_bytes(&self) -> &[u8; 32]
@@ -1873,6 +1912,13 @@ pub fn myc::MycSignerRequestRecord::fmt(&self, &mut core::fmt::Formatter<'_>) ->
pub struct myc::MycStagedStateRestore
impl core::fmt::Debug for myc::MycStagedStateRestore
pub fn myc::MycStagedStateRestore::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycStateBackupArgsV1
+impl myc::MycStateBackupArgsV1
+pub const fn myc::MycStateBackupArgsV1::expected_generation(&self) -> u64
+pub fn myc::MycStateBackupArgsV1::operation_id(&self) -> &str
+pub fn myc::MycStateBackupArgsV1::target(&self) -> &std::path::Path
+impl core::fmt::Debug for myc::MycStateBackupArgsV1
+pub fn myc::MycStateBackupArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycStateCatalogError
impl myc::MycStateCatalogError
pub const fn myc::MycStateCatalogError::code(self) -> &'static str
@@ -1983,6 +2029,14 @@ impl core::fmt::Debug for myc::MycStateRepositoryError
pub fn myc::MycStateRepositoryError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for myc::MycStateRepositoryError
pub fn myc::MycStateRepositoryError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycStateRestoreArgsV1
+impl myc::MycStateRestoreArgsV1
+pub fn myc::MycStateRestoreArgsV1::bundle(&self) -> &std::path::Path
+pub fn myc::MycStateRestoreArgsV1::manifest(&self) -> &std::path::Path
+pub const fn myc::MycStateRestoreArgsV1::manifest_sha256(&self) -> radroots_service_sqlite::backup::manifest::BackupManifestSha256
+pub const fn myc::MycStateRestoreArgsV1::maximum_state_bytes(&self) -> core::num::nonzero::NonZeroU64
+impl core::fmt::Debug for myc::MycStateRestoreArgsV1
+pub fn myc::MycStateRestoreArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycStatusBuildInfoV1
impl myc::MycStatusBuildInfoV1
pub fn myc::MycStatusBuildInfoV1::new(myc::MycStatusBuildMode, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>) -> core::result::Result<Self, myc::MycStatusError>
@@ -2216,8 +2270,12 @@ pub fn myc::admit_myc_nip46_event(myc::MycNip46AdmissionLimits, &[u8]) -> core::
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 fn myc::execute_myc_cli_v1(myc::MycCliInvocationV1) -> myc::MycProcessResult
pub async fn myc::finalize_myc_state_restore(myc::MycStagedStateRestore) -> core::result::Result<(), myc::MycStateMaintenanceError>
+pub fn myc::initialize_myc_config_document(&myc::MycRuntimeContext, &[u8]) -> core::result::Result<myc::MycConfigDocumentV1, myc::MycConfigLoadError>
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 fn myc::load_myc_config_candidate(&myc::MycRuntimeContext, &std::path::Path) -> core::result::Result<myc::MycConfigDocumentV1, myc::MycConfigLoadError>
+pub fn myc::load_myc_config_document(&myc::MycRuntimeContext) -> core::result::Result<myc::MycConfigDocumentV1, myc::MycConfigLoadError>
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>
@@ -2225,7 +2283,9 @@ pub fn myc::myc_status_cache(radroots_runtime_paths::identifier::InstanceId, myc
pub fn myc::open_myc_encrypted_identity(&myc::MycProviderBinding, &myc::MycWrappingCredential) -> core::result::Result<myc::MycDecryptedIdentity, myc::MycEncryptedIdentityEnvelopeError>
pub async fn myc::open_myc_runtime_foundation(myc::MycRuntimeContext, myc::MycConfigDocumentV1, myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<myc::MycRuntimeFoundation, myc::MycRuntimeFoundationError>
pub async fn myc::open_myc_state_inspection(&myc::MycRuntimeContext, &myc::MycStateMetadata) -> core::result::Result<myc::MycStateHost, myc::MycStateHostError>
+pub async fn myc::open_myc_state_inspection_from_config(&myc::MycRuntimeContext, &myc::MycConfigDocumentV1) -> core::result::Result<myc::MycStateHost, myc::MycStateHostError>
pub async fn myc::open_myc_state_read_write(&myc::MycRuntimeContext, &myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<myc::MycStateHost, myc::MycStateHostError>
+pub async fn myc::open_myc_state_read_write_from_config(&myc::MycRuntimeContext, &myc::MycConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<myc::MycStateHost, myc::MycStateHostError>
pub fn myc::parse_myc_cli_v1_from<I, T>(I) -> core::result::Result<myc::MycCliInvocationV1, myc::MycCliV1Error> where I: core::iter::traits::collect::IntoIterator<Item = T>, T: core::convert::Into<std::ffi::os_str::OsString> + core::clone::Clone
pub fn myc::parse_myc_config_v1(&[u8], myc::MycConfigProfile) -> core::result::Result<myc::MycConfigDocumentV1, myc::MycConfigV1Error>
pub const fn myc::plan_myc_cli_v1(&myc::MycCliInvocationV1) -> myc::MycCliExecutionPlanV1
diff --git a/contracts/services_hardening/config.v1.example.toml b/contracts/services_hardening/config.v1.example.toml
@@ -140,6 +140,10 @@ samples = 512
labels_per_sample = 8
render_utf8_bytes = 1048576
+[resource_limits.runtime]
+worker_threads = 4
+blocking_threads = 8
+
[discovery]
enabled = true
domain = "myc.example.test"
diff --git a/contracts/services_hardening/config.v1.schema.json b/contracts/services_hardening/config.v1.schema.json
@@ -402,6 +402,14 @@
"render_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" }
}
},
+ "runtime_limits": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "worker_threads": { "type": "integer", "minimum": 2, "maximum": 32, "default": 4, "x-radroots-default-source": "engineering_safety" },
+ "blocking_threads": { "type": "integer", "minimum": 1, "maximum": 32, "default": 8, "x-radroots-default-source": "engineering_safety" }
+ }
+ },
"resource_limits": {
"type": "object",
"additionalProperties": false,
@@ -409,7 +417,8 @@
"admin": { "$ref": "#/$defs/admin_limits" },
"events": { "$ref": "#/$defs/event_limits" },
"queues": { "$ref": "#/$defs/queue_limits" },
- "metrics": { "$ref": "#/$defs/metrics_limits" }
+ "metrics": { "$ref": "#/$defs/metrics_limits" },
+ "runtime": { "$ref": "#/$defs/runtime_limits" }
}
},
"discovery_metadata": {
diff --git a/contracts/services_hardening/operator_contract.v1.json b/contracts/services_hardening/operator_contract.v1.json
@@ -223,12 +223,78 @@
"live_direct_sqlite_access": false,
"live_mutation_offline_fallback": false,
"read_only_offline_fallback_requires_free_daemon_writer_lock": true,
+ "bootstrap": {
+ "output_modes": ["human", "json"],
+ "default_output_mode": "human",
+ "stdout": "results_only",
+ "stderr": "diagnostics_only",
+ "config_init": {
+ "source": "bounded_nonsecret_toml_stdin",
+ "persistence": "create_new_selected_path_mode_0600_file_and_parent_sync"
+ },
+ "config_loader": {
+ "path": "selected_absolute_explicit_or_canonical_default",
+ "maximum_utf8_bytes": 1048576,
+ "no_follow": true,
+ "regular_file": true,
+ "single_link": true,
+ "effective_user_owner": true,
+ "group_or_other_write": false,
+ "revalidate_after_read": ["parent_device_inode", "file_device_inode", "file_length"]
+ },
+ "config_apply": {
+ "candidate_argument": "--candidate-config_absolute_path",
+ "current_source": "selected_config_path",
+ "mutates_config_files": false
+ },
+ "identity": {
+ "role_argument": "--role_transport_user_discovery",
+ "init_provider": "configured_encrypted_file_only",
+ "init_secret_source": "stdin_fixed_binary_v1_117_bytes"
+ },
+ "state_init": {
+ "source_generation": "system_entropy_nonzero_32_bytes",
+ "created_at": "system_wall_clock",
+ "existing_state_open": "sealed_intent_discovers_actual_metadata"
+ },
+ "runtime": {
+ "owner": "myc_binary",
+ "count_per_process": 1,
+ "worker_threads_default": 4,
+ "worker_threads_range": [2, 32],
+ "blocking_threads_default": 8,
+ "blocking_threads_range": [1, 32],
+ "cpu_derived_defaults": false
+ },
+ "backup": {
+ "operation_id_argument": "--operation-id",
+ "target_argument": "--target-new-absolute-directory",
+ "expected_generation_argument": "--expected-generation",
+ "confirmation_argument": "--confirm",
+ "offline_stdout": "exact_canonical_manifest_bytes_no_trailing_newline"
+ },
+ "restore": {
+ "manifest_argument": "--manifest-absolute-file",
+ "manifest_digest_argument": "--manifest-sha256",
+ "bundle_argument": "--bundle-absolute-directory",
+ "maximum_state_bytes_argument": "--maximum-state-bytes",
+ "confirmation_argument": "--confirm",
+ "expected_identity_source": "trusted_digest_bound_manifest_before_live_database_open"
+ },
+ "run": {
+ "config_source": "secure_selected_path_loader",
+ "runtime_owner": "myc_binary",
+ "graph_owner": "myc-runtime-graph-shutdown"
+ },
+ "unsupported_command_success": false
+ },
"commands": [
{ "command": "run", "primary_authority": "daemon" },
{ "command": "config init", "primary_authority": "offline", "offline_operation": "config" },
{ "command": "config validate", "primary_authority": "offline", "offline_operation": "config" },
{ "command": "config show", "primary_authority": "offline", "offline_operation": "config" },
{ "command": "config schema", "primary_authority": "offline", "offline_operation": "config" },
+ { "command": "config apply", "primary_authority": "offline", "offline_operation": "config" },
{ "command": "state init", "primary_authority": "offline", "offline_operation": "state_exclusive" },
{ "command": "state status", "primary_authority": "live_unix_admin", "admin_route": "/v1/state/status", "offline_operation": "state_read_only", "daemon_unavailable_offline_fallback": true },
{ "command": "state backup", "primary_authority": "live_unix_admin", "admin_route": "/v1/state/backup", "offline_operation": "state_read_only", "daemon_unavailable_offline_fallback": true },
diff --git a/src/admin_v1.rs b/src/admin_v1.rs
@@ -692,7 +692,7 @@ where
Ok(MycAdminRouter { inner: router })
}
-fn admin_transport_limits(
+pub(crate) fn admin_transport_limits(
configuration: &crate::MycConfigDocumentV1,
) -> Result<AdminTransportLimits, MycAdminServerError> {
let admin = configuration
@@ -712,6 +712,16 @@ fn admin_transport_limits(
AdminTransportLimits::new(values).map_err(|_| invalid_admin_configuration())
}
+pub(crate) fn admit_admin_response_value(
+ route: MycAdminRoute,
+ value: &Value,
+) -> Result<Box<[u8]>, MycAdminDocumentError> {
+ let bytes = serde_json::to_vec(value)
+ .map_err(|_| MycAdminDocumentError::new(MycAdminDocumentErrorKind::Malformed))?;
+ MycAdminResponseDocument::from_canonical_bytes(route, &bytes)
+ .map(|document| document.canonical_bytes)
+}
+
fn admin_u64(value: &Value, pointer: &str) -> Result<u64, MycAdminServerError> {
value
.pointer(pointer)
diff --git a/src/cli_bootstrap.rs b/src/cli_bootstrap.rs
@@ -0,0 +1,131 @@
+//! Fixed, zeroizing bootstrap documents consumed only from standard input.
+
+use std::io::Read;
+
+use zeroize::Zeroizing;
+
+use crate::MycEncryptedIdentityProvisioningMaterial;
+
+pub(crate) const MYC_IDENTITY_PROVISIONING_DOCUMENT_BYTES: usize = 117;
+const MYC_IDENTITY_PROVISIONING_MAGIC: &[u8; 4] = b"MYIP";
+const MYC_IDENTITY_PROVISIONING_VERSION: u8 = 1;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum MycBootstrapDocumentError {
+ Io,
+ InvalidLength,
+ InvalidHeader,
+ InvalidMaterial,
+}
+
+pub(crate) fn read_identity_provisioning_document(
+ mut reader: impl Read,
+) -> Result<MycEncryptedIdentityProvisioningMaterial, MycBootstrapDocumentError> {
+ let mut document = Zeroizing::new([0_u8; MYC_IDENTITY_PROVISIONING_DOCUMENT_BYTES + 1]);
+ let mut length = 0_usize;
+ while length < document.len() {
+ match reader.read(&mut document[length..]) {
+ Ok(0) => break,
+ Ok(read) => length = length.saturating_add(read),
+ Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+ Err(_) => return Err(MycBootstrapDocumentError::Io),
+ }
+ }
+ if length != MYC_IDENTITY_PROVISIONING_DOCUMENT_BYTES {
+ return Err(MycBootstrapDocumentError::InvalidLength);
+ }
+ if &document[..4] != MYC_IDENTITY_PROVISIONING_MAGIC
+ || document[4] != MYC_IDENTITY_PROVISIONING_VERSION
+ {
+ return Err(MycBootstrapDocumentError::InvalidHeader);
+ }
+
+ let mut identity_secret = [0_u8; 32];
+ let mut data_key = [0_u8; 32];
+ let mut envelope_nonce = [0_u8; 24];
+ let mut wrapping_nonce = [0_u8; 24];
+ identity_secret.copy_from_slice(&document[5..37]);
+ data_key.copy_from_slice(&document[37..69]);
+ envelope_nonce.copy_from_slice(&document[69..93]);
+ wrapping_nonce.copy_from_slice(&document[93..117]);
+ MycEncryptedIdentityProvisioningMaterial::new(
+ identity_secret,
+ data_key,
+ envelope_nonce,
+ wrapping_nonce,
+ )
+ .map_err(|_| MycBootstrapDocumentError::InvalidMaterial)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::io::{self, Cursor};
+
+ use super::*;
+
+ fn valid_document() -> [u8; MYC_IDENTITY_PROVISIONING_DOCUMENT_BYTES] {
+ let mut document = [0_u8; MYC_IDENTITY_PROVISIONING_DOCUMENT_BYTES];
+ document[..4].copy_from_slice(MYC_IDENTITY_PROVISIONING_MAGIC);
+ document[4] = MYC_IDENTITY_PROVISIONING_VERSION;
+ document[5..37].copy_from_slice(&[1; 32]);
+ document[37..69].copy_from_slice(&[2; 32]);
+ document[69..93].copy_from_slice(&[3; 24]);
+ document[93..117].copy_from_slice(&[4; 24]);
+ document
+ }
+
+ #[test]
+ fn exact_document_is_admitted_and_debug_is_redacted() {
+ let material = read_identity_provisioning_document(Cursor::new(valid_document()))
+ .expect("exact provisioning document");
+ assert_eq!(
+ format!("{material:?}"),
+ "MycEncryptedIdentityProvisioningMaterial([redacted])"
+ );
+ }
+
+ #[test]
+ fn short_trailing_wrong_header_and_invalid_material_fail_closed() {
+ let valid = valid_document();
+ assert_eq!(
+ read_identity_provisioning_document(Cursor::new(&valid[..116])).unwrap_err(),
+ MycBootstrapDocumentError::InvalidLength
+ );
+ let mut trailing = valid.to_vec();
+ trailing.push(0);
+ assert_eq!(
+ read_identity_provisioning_document(Cursor::new(trailing)).unwrap_err(),
+ MycBootstrapDocumentError::InvalidLength
+ );
+ for index in [0, 4] {
+ let mut invalid = valid;
+ invalid[index] ^= 0xff;
+ assert_eq!(
+ read_identity_provisioning_document(Cursor::new(invalid)).unwrap_err(),
+ MycBootstrapDocumentError::InvalidHeader
+ );
+ }
+ let mut invalid = valid;
+ invalid[37..69].fill(0);
+ assert_eq!(
+ read_identity_provisioning_document(Cursor::new(invalid)).unwrap_err(),
+ MycBootstrapDocumentError::InvalidMaterial
+ );
+ }
+
+ struct FailingReader;
+
+ impl Read for FailingReader {
+ fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
+ Err(io::Error::other("sensitive source"))
+ }
+ }
+
+ #[test]
+ fn input_errors_discard_the_raw_source() {
+ assert_eq!(
+ read_identity_provisioning_document(FailingReader).unwrap_err(),
+ MycBootstrapDocumentError::Io
+ );
+ }
+}
diff --git a/src/cli_v1.rs b/src/cli_v1.rs
@@ -3,10 +3,13 @@
use std::error::Error;
use std::ffi::OsString;
use std::fmt;
+use std::num::NonZeroU64;
use std::path::{Component, Path, PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use radroots_runtime_paths::InstanceId;
+use radroots_service_host::AdminOperationId;
+use radroots_service_sqlite::BackupManifestSha256;
/// The exact bootstrap profile selected by the operator.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
@@ -17,8 +20,17 @@ pub enum MycBootstrapProfileV1 {
RepoLocal,
}
+/// The only two governed command-result encodings.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycCliOutputModeV1 {
+ #[default]
+ Human,
+ Json,
+}
+
/// The exact governed top-level Myc command inventory.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum MycCommandV1 {
Run,
Config(MycConfigCommandV1),
@@ -29,31 +41,150 @@ pub enum MycCommandV1 {
}
/// Governed configuration commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum MycConfigCommandV1 {
Init,
Validate,
Show,
Schema,
+ Apply(MycConfigApplyArgsV1),
}
/// Governed state commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum MycStateCommandV1 {
Init,
Status,
- Backup,
- Restore,
+ Backup(MycStateBackupArgsV1),
+ Restore(MycStateRestoreArgsV1),
Verify,
Migrate,
}
/// Governed identity commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum MycIdentityCommandV1 {
- Init,
- Status,
- ExportPublic,
+ Init(MycIdentityCommandArgsV1),
+ Status(MycIdentityCommandArgsV1),
+ ExportPublic(MycIdentityCommandArgsV1),
+}
+
+/// Exact offline configuration-apply input.
+#[derive(PartialEq, Eq)]
+pub struct MycConfigApplyArgsV1 {
+ candidate_config: PathBuf,
+}
+
+impl MycConfigApplyArgsV1 {
+ #[must_use]
+ pub fn candidate_config(&self) -> &Path {
+ &self.candidate_config
+ }
+}
+
+impl fmt::Debug for MycConfigApplyArgsV1 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycConfigApplyArgsV1([redacted])")
+ }
+}
+
+/// Exact online-or-offline state-backup input.
+#[derive(PartialEq, Eq)]
+pub struct MycStateBackupArgsV1 {
+ operation_id: Box<str>,
+ target: PathBuf,
+ expected_generation: u64,
+}
+
+impl MycStateBackupArgsV1 {
+ #[must_use]
+ pub fn operation_id(&self) -> &str {
+ &self.operation_id
+ }
+
+ #[must_use]
+ pub fn target(&self) -> &Path {
+ &self.target
+ }
+
+ #[must_use]
+ pub const fn expected_generation(&self) -> u64 {
+ self.expected_generation
+ }
+}
+
+impl fmt::Debug for MycStateBackupArgsV1 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycStateBackupArgsV1")
+ .field("operation_id", &"[redacted]")
+ .field("target", &"[redacted]")
+ .field("expected_generation", &self.expected_generation)
+ .finish()
+ }
+}
+
+/// Exact offline restore-verification input.
+#[derive(PartialEq, Eq)]
+pub struct MycStateRestoreArgsV1 {
+ manifest: PathBuf,
+ manifest_sha256: BackupManifestSha256,
+ bundle: PathBuf,
+ maximum_state_bytes: NonZeroU64,
+}
+
+impl MycStateRestoreArgsV1 {
+ #[must_use]
+ pub fn manifest(&self) -> &Path {
+ &self.manifest
+ }
+
+ #[must_use]
+ pub const fn manifest_sha256(&self) -> BackupManifestSha256 {
+ self.manifest_sha256
+ }
+
+ #[must_use]
+ pub fn bundle(&self) -> &Path {
+ &self.bundle
+ }
+
+ #[must_use]
+ pub const fn maximum_state_bytes(&self) -> NonZeroU64 {
+ self.maximum_state_bytes
+ }
+}
+
+impl fmt::Debug for MycStateRestoreArgsV1 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycStateRestoreArgsV1([redacted])")
+ }
+}
+
+/// Role input required by every identity command.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct MycIdentityCommandArgsV1 {
+ role: crate::MycProviderRole,
+}
+
+impl MycIdentityCommandArgsV1 {
+ #[must_use]
+ pub const fn role(self) -> crate::MycProviderRole {
+ self.role
+ }
+}
+
+impl fmt::Debug for MycCommandV1 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::Run => "MycCommandV1::Run",
+ Self::Config(_) => "MycCommandV1::Config([redacted])",
+ Self::State(_) => "MycCommandV1::State([redacted])",
+ Self::Identity(_) => "MycCommandV1::Identity([redacted])",
+ Self::Status => "MycCommandV1::Status",
+ Self::Doctor => "MycCommandV1::Doctor",
+ })
+ }
}
/// The only three process authorities selected by the hardened CLI.
@@ -174,6 +305,7 @@ pub enum MycCliV1ErrorKind {
InvalidRepoLocalRoot,
UnexpectedRepoLocalRoot,
InvalidConfigPath,
+ InvalidCommandInput,
}
impl MycCliV1ErrorKind {
@@ -186,6 +318,7 @@ impl MycCliV1ErrorKind {
"repo-local root is forbidden outside the repo-local profile"
}
Self::InvalidConfigPath => "configuration path must be absolute without traversal",
+ Self::InvalidCommandInput => "command input is invalid",
}
}
}
@@ -231,6 +364,7 @@ pub struct MycCliInvocationV1 {
instance: InstanceId,
repo_local_root: Option<PathBuf>,
config_path: Option<PathBuf>,
+ output_mode: MycCliOutputModeV1,
command: MycCommandV1,
}
@@ -259,10 +393,16 @@ impl MycCliInvocationV1 {
self.config_path.as_deref()
}
+ /// Returns the exact result encoding selected once at admission.
+ #[must_use]
+ pub const fn output_mode(&self) -> MycCliOutputModeV1 {
+ self.output_mode
+ }
+
/// Returns the exact governed command selection.
#[must_use]
- pub const fn command(&self) -> MycCommandV1 {
- self.command
+ pub const fn command(&self) -> &MycCommandV1 {
+ &self.command
}
}
@@ -280,6 +420,7 @@ impl fmt::Debug for MycCliInvocationV1 {
"config_path",
&self.config_path.as_ref().map(|_| "[redacted]"),
)
+ .field("output_mode", &self.output_mode)
.field("command", &self.command)
.finish()
}
@@ -317,7 +458,8 @@ where
instance,
repo_local_root: parsed.repo_local_root,
config_path: parsed.config,
- command: parsed.command.into(),
+ output_mode: parsed.output.into(),
+ command: admit_command(parsed.command)?,
})
}
@@ -328,11 +470,11 @@ where
/// arguments. In particular, no live command receives direct SQLite authority.
#[must_use]
pub const fn plan_myc_cli_v1(invocation: &MycCliInvocationV1) -> MycCliExecutionPlanV1 {
- match invocation.command {
+ match &invocation.command {
MycCommandV1::Run => daemon_plan(),
MycCommandV1::Config(_) => offline_plan(MycCliOfflineOperationV1::Config),
MycCommandV1::State(MycStateCommandV1::Init)
- | MycCommandV1::State(MycStateCommandV1::Restore)
+ | MycCommandV1::State(MycStateCommandV1::Restore(_))
| MycCommandV1::State(MycStateCommandV1::Verify)
| MycCommandV1::State(MycStateCommandV1::Migrate) => {
offline_plan(MycCliOfflineOperationV1::StateExclusive)
@@ -341,18 +483,18 @@ pub const fn plan_myc_cli_v1(invocation: &MycCliInvocationV1) -> MycCliExecution
MycCliAdminOperationV1::StateStatus,
MycCliOfflineOperationV1::StateReadOnly,
),
- MycCommandV1::State(MycStateCommandV1::Backup) => read_only_admin_plan(
+ MycCommandV1::State(MycStateCommandV1::Backup(_)) => read_only_admin_plan(
MycCliAdminOperationV1::StateBackup,
MycCliOfflineOperationV1::StateReadOnly,
),
- MycCommandV1::Identity(MycIdentityCommandV1::Init) => {
+ MycCommandV1::Identity(MycIdentityCommandV1::Init(_)) => {
offline_plan(MycCliOfflineOperationV1::IdentityExclusive)
}
- MycCommandV1::Identity(MycIdentityCommandV1::Status) => read_only_admin_plan(
+ MycCommandV1::Identity(MycIdentityCommandV1::Status(_)) => read_only_admin_plan(
MycCliAdminOperationV1::IdentityStatus,
MycCliOfflineOperationV1::IdentityReadOnly,
),
- MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic) => read_only_admin_plan(
+ MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic(_)) => read_only_admin_plan(
MycCliAdminOperationV1::IdentityPublic,
MycCliOfflineOperationV1::IdentityReadOnly,
),
@@ -421,6 +563,9 @@ fn validate_bootstrap_paths(
fn valid_absolute_path(path: &Path, require_non_root: bool) -> bool {
path.is_absolute()
&& (!require_non_root || path.parent().is_some())
+ && path
+ .to_str()
+ .is_some_and(|value| !value.is_empty() && value.len() <= 4_096)
&& !path
.components()
.any(|component| matches!(component, Component::ParentDir))
@@ -437,6 +582,8 @@ struct RawMycCliV1 {
repo_local_root: Option<PathBuf>,
#[arg(long, global = true)]
config: Option<PathBuf>,
+ #[arg(long, global = true, value_enum, default_value_t = RawOutputMode::Human)]
+ output: RawOutputMode,
#[command(subcommand)]
command: RawCommand,
}
@@ -448,6 +595,22 @@ enum RawProfile {
RepoLocal,
}
+#[derive(Clone, Copy, Default, ValueEnum)]
+enum RawOutputMode {
+ #[default]
+ Human,
+ Json,
+}
+
+impl From<RawOutputMode> for MycCliOutputModeV1 {
+ fn from(value: RawOutputMode) -> Self {
+ match value {
+ RawOutputMode::Human => Self::Human,
+ RawOutputMode::Json => Self::Json,
+ }
+ }
+}
+
impl From<RawProfile> for MycBootstrapProfileV1 {
fn from(value: RawProfile) -> Self {
match value {
@@ -477,78 +640,166 @@ enum RawCommand {
Doctor,
}
-impl From<RawCommand> for MycCommandV1 {
- fn from(value: RawCommand) -> Self {
- match value {
- RawCommand::Run => Self::Run,
- RawCommand::Config { command } => Self::Config(command.into()),
- RawCommand::State { command } => Self::State(command.into()),
- RawCommand::Identity { command } => Self::Identity(command.into()),
- RawCommand::Status => Self::Status,
- RawCommand::Doctor => Self::Doctor,
- }
- }
-}
-
#[derive(Subcommand)]
enum RawConfigCommand {
Init,
Validate,
Show,
Schema,
-}
-
-impl From<RawConfigCommand> for MycConfigCommandV1 {
- fn from(value: RawConfigCommand) -> Self {
- match value {
- RawConfigCommand::Init => Self::Init,
- RawConfigCommand::Validate => Self::Validate,
- RawConfigCommand::Show => Self::Show,
- RawConfigCommand::Schema => Self::Schema,
- }
- }
+ Apply {
+ #[arg(long = "candidate-config")]
+ candidate_config: PathBuf,
+ },
}
#[derive(Subcommand)]
enum RawStateCommand {
Init,
Status,
- Backup,
- Restore,
+ Backup {
+ #[arg(long = "operation-id")]
+ operation_id: String,
+ #[arg(long)]
+ target: PathBuf,
+ #[arg(long = "expected-generation")]
+ expected_generation: u64,
+ #[arg(long, required = true)]
+ confirm: bool,
+ },
+ Restore {
+ #[arg(long)]
+ manifest: PathBuf,
+ #[arg(long = "manifest-sha256")]
+ manifest_sha256: String,
+ #[arg(long)]
+ bundle: PathBuf,
+ #[arg(long = "maximum-state-bytes")]
+ maximum_state_bytes: u64,
+ #[arg(long, required = true)]
+ confirm: bool,
+ },
Verify,
Migrate,
}
-impl From<RawStateCommand> for MycStateCommandV1 {
- fn from(value: RawStateCommand) -> Self {
- match value {
- RawStateCommand::Init => Self::Init,
- RawStateCommand::Status => Self::Status,
- RawStateCommand::Backup => Self::Backup,
- RawStateCommand::Restore => Self::Restore,
- RawStateCommand::Verify => Self::Verify,
- RawStateCommand::Migrate => Self::Migrate,
- }
- }
-}
-
#[derive(Subcommand)]
enum RawIdentityCommand {
- Init,
- Status,
- ExportPublic,
+ Init {
+ #[arg(long, value_enum)]
+ role: RawIdentityRole,
+ },
+ Status {
+ #[arg(long, value_enum)]
+ role: RawIdentityRole,
+ },
+ ExportPublic {
+ #[arg(long, value_enum)]
+ role: RawIdentityRole,
+ },
}
-impl From<RawIdentityCommand> for MycIdentityCommandV1 {
- fn from(value: RawIdentityCommand) -> Self {
+#[derive(Clone, Copy, ValueEnum)]
+enum RawIdentityRole {
+ Transport,
+ User,
+ Discovery,
+}
+
+impl From<RawIdentityRole> for crate::MycProviderRole {
+ fn from(value: RawIdentityRole) -> Self {
match value {
- RawIdentityCommand::Init => Self::Init,
- RawIdentityCommand::Status => Self::Status,
- RawIdentityCommand::ExportPublic => Self::ExportPublic,
+ RawIdentityRole::Transport => Self::Transport,
+ RawIdentityRole::User => Self::User,
+ RawIdentityRole::Discovery => Self::Discovery,
}
}
}
+fn admit_command(command: RawCommand) -> Result<MycCommandV1, MycCliV1Error> {
+ let invalid = || MycCliV1Error::new(MycCliV1ErrorKind::InvalidCommandInput);
+ Ok(match command {
+ RawCommand::Run => MycCommandV1::Run,
+ RawCommand::Config { command } => MycCommandV1::Config(match command {
+ RawConfigCommand::Init => MycConfigCommandV1::Init,
+ RawConfigCommand::Validate => MycConfigCommandV1::Validate,
+ RawConfigCommand::Show => MycConfigCommandV1::Show,
+ RawConfigCommand::Schema => MycConfigCommandV1::Schema,
+ RawConfigCommand::Apply { candidate_config } => {
+ if !valid_absolute_path(&candidate_config, true) {
+ return Err(invalid());
+ }
+ MycConfigCommandV1::Apply(MycConfigApplyArgsV1 { candidate_config })
+ }
+ }),
+ RawCommand::State { command } => MycCommandV1::State(match command {
+ RawStateCommand::Init => MycStateCommandV1::Init,
+ RawStateCommand::Status => MycStateCommandV1::Status,
+ RawStateCommand::Backup {
+ operation_id,
+ target,
+ expected_generation,
+ confirm,
+ } => {
+ if !confirm || !valid_absolute_path(&target, true) {
+ return Err(invalid());
+ }
+ let operation_id = AdminOperationId::new(operation_id).map_err(|_| invalid())?;
+ MycStateCommandV1::Backup(MycStateBackupArgsV1 {
+ operation_id: operation_id.as_str().into(),
+ target,
+ expected_generation,
+ })
+ }
+ RawStateCommand::Restore {
+ manifest,
+ manifest_sha256,
+ bundle,
+ maximum_state_bytes,
+ confirm,
+ } => {
+ if !confirm
+ || !valid_absolute_path(&manifest, true)
+ || !valid_absolute_path(&bundle, true)
+ {
+ return Err(invalid());
+ }
+ if manifest_sha256.len() != 64
+ || manifest_sha256
+ .bytes()
+ .any(|byte| !matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
+ {
+ return Err(invalid());
+ }
+ let mut digest = [0_u8; 32];
+ hex::decode_to_slice(manifest_sha256, &mut digest).map_err(|_| invalid())?;
+ let maximum_state_bytes =
+ NonZeroU64::new(maximum_state_bytes).ok_or_else(invalid)?;
+ MycStateCommandV1::Restore(MycStateRestoreArgsV1 {
+ manifest,
+ manifest_sha256: BackupManifestSha256::from_bytes(digest),
+ bundle,
+ maximum_state_bytes,
+ })
+ }
+ RawStateCommand::Verify => MycStateCommandV1::Verify,
+ RawStateCommand::Migrate => MycStateCommandV1::Migrate,
+ }),
+ RawCommand::Identity { command } => MycCommandV1::Identity(match command {
+ RawIdentityCommand::Init { role } => {
+ MycIdentityCommandV1::Init(MycIdentityCommandArgsV1 { role: role.into() })
+ }
+ RawIdentityCommand::Status { role } => {
+ MycIdentityCommandV1::Status(MycIdentityCommandArgsV1 { role: role.into() })
+ }
+ RawIdentityCommand::ExportPublic { role } => {
+ MycIdentityCommandV1::ExportPublic(MycIdentityCommandArgsV1 { role: role.into() })
+ }
+ }),
+ RawCommand::Status => MycCommandV1::Status,
+ RawCommand::Doctor => MycCommandV1::Doctor,
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -562,64 +813,93 @@ mod tests {
#[test]
fn exact_command_inventory_parses() {
let vectors = [
- (&["run"][..], MycCommandV1::Run),
- (
- &["config", "init"][..],
- MycCommandV1::Config(MycConfigCommandV1::Init),
- ),
- (
- &["config", "validate"][..],
- MycCommandV1::Config(MycConfigCommandV1::Validate),
- ),
- (
- &["config", "show"][..],
- MycCommandV1::Config(MycConfigCommandV1::Show),
- ),
- (
- &["config", "schema"][..],
- MycCommandV1::Config(MycConfigCommandV1::Schema),
- ),
- (
- &["state", "init"][..],
- MycCommandV1::State(MycStateCommandV1::Init),
- ),
- (
- &["state", "status"][..],
- MycCommandV1::State(MycStateCommandV1::Status),
- ),
- (
- &["state", "backup"][..],
- MycCommandV1::State(MycStateCommandV1::Backup),
- ),
+ (&["run"][..], "run"),
+ (&["config", "init"][..], "config_init"),
+ (&["config", "validate"][..], "config_validate"),
+ (&["config", "show"][..], "config_show"),
+ (&["config", "schema"][..], "config_schema"),
(
- &["state", "restore"][..],
- MycCommandV1::State(MycStateCommandV1::Restore),
+ &["config", "apply", "--candidate-config", "/candidate.toml"][..],
+ "config_apply",
),
+ (&["state", "init"][..], "state_init"),
+ (&["state", "status"][..], "state_status"),
(
- &["state", "verify"][..],
- MycCommandV1::State(MycStateCommandV1::Verify),
+ &[
+ "state",
+ "backup",
+ "--operation-id",
+ "backup-01",
+ "--target",
+ "/backup/new",
+ "--expected-generation",
+ "7",
+ "--confirm",
+ ][..],
+ "state_backup",
),
(
- &["state", "migrate"][..],
- MycCommandV1::State(MycStateCommandV1::Migrate),
+ &[
+ "state",
+ "restore",
+ "--manifest",
+ "/backup/manifest.json",
+ "--manifest-sha256",
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ "--bundle",
+ "/backup/bundle",
+ "--maximum-state-bytes",
+ "1048576",
+ "--confirm",
+ ][..],
+ "state_restore",
),
+ (&["state", "verify"][..], "state_verify"),
+ (&["state", "migrate"][..], "state_migrate"),
(
- &["identity", "init"][..],
- MycCommandV1::Identity(MycIdentityCommandV1::Init),
+ &["identity", "init", "--role", "transport"][..],
+ "identity_init",
),
(
- &["identity", "status"][..],
- MycCommandV1::Identity(MycIdentityCommandV1::Status),
+ &["identity", "status", "--role", "user"][..],
+ "identity_status",
),
(
- &["identity", "export-public"][..],
- MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic),
+ &["identity", "export-public", "--role", "discovery"][..],
+ "identity_export_public",
),
- (&["status"][..], MycCommandV1::Status),
- (&["doctor"][..], MycCommandV1::Doctor),
+ (&["status"][..], "status"),
+ (&["doctor"][..], "doctor"),
];
for (arguments, expected) in vectors {
- assert_eq!(parse(arguments).expect("command").command(), expected);
+ assert_eq!(
+ command_name(parse(arguments).expect("command").command()),
+ expected
+ );
+ }
+ }
+
+ fn command_name(command: &MycCommandV1) -> &'static str {
+ match command {
+ MycCommandV1::Run => "run",
+ MycCommandV1::Config(MycConfigCommandV1::Init) => "config_init",
+ MycCommandV1::Config(MycConfigCommandV1::Validate) => "config_validate",
+ MycCommandV1::Config(MycConfigCommandV1::Show) => "config_show",
+ MycCommandV1::Config(MycConfigCommandV1::Schema) => "config_schema",
+ MycCommandV1::Config(MycConfigCommandV1::Apply(_)) => "config_apply",
+ MycCommandV1::State(MycStateCommandV1::Init) => "state_init",
+ MycCommandV1::State(MycStateCommandV1::Status) => "state_status",
+ MycCommandV1::State(MycStateCommandV1::Backup(_)) => "state_backup",
+ MycCommandV1::State(MycStateCommandV1::Restore(_)) => "state_restore",
+ MycCommandV1::State(MycStateCommandV1::Verify) => "state_verify",
+ MycCommandV1::State(MycStateCommandV1::Migrate) => "state_migrate",
+ MycCommandV1::Identity(MycIdentityCommandV1::Init(_)) => "identity_init",
+ MycCommandV1::Identity(MycIdentityCommandV1::Status(_)) => "identity_status",
+ MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic(_)) => {
+ "identity_export_public"
+ }
+ MycCommandV1::Status => "status",
+ MycCommandV1::Doctor => "doctor",
}
}
@@ -776,6 +1056,38 @@ mod tests {
}
#[test]
+ fn restore_digest_is_exact_lowercase_hex_before_decode() {
+ for digest in [
+ "1".repeat(63),
+ "1".repeat(65),
+ "A".repeat(64),
+ "g".repeat(64),
+ "1".repeat(1_048_576),
+ ] {
+ let error = parse_myc_cli_v1_from([
+ "myc",
+ "--profile",
+ "service-host",
+ "--instance",
+ "primary",
+ "state",
+ "restore",
+ "--manifest",
+ "/backup/manifest.json",
+ "--manifest-sha256",
+ digest.as_str(),
+ "--bundle",
+ "/backup/bundle",
+ "--maximum-state-bytes",
+ "1048576",
+ "--confirm",
+ ])
+ .expect_err("invalid digest");
+ assert_eq!(error.kind(), MycCliV1ErrorKind::InvalidCommandInput);
+ }
+ }
+
+ #[test]
fn missing_unknown_and_prototype_arguments_fail_without_sources() {
for arguments in [
vec!["myc", "run"],
diff --git a/src/config_loader.rs b/src/config_loader.rs
@@ -0,0 +1,650 @@
+//! Secure descriptor-bound configuration loading and create-new initialization.
+
+use core::fmt;
+use std::{error::Error, path::Path};
+
+use crate::{
+ MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error,
+ MycRuntimeContext, parse_myc_config_v1,
+};
+
+/// Stable source-free secure configuration I/O failure classification.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycConfigLoadErrorKind {
+ InvalidPath,
+ Missing,
+ AlreadyExists,
+ InsecureParent,
+ InsecureArtifact,
+ TooLarge,
+ Io,
+ InvalidDocument,
+ UnsupportedPlatform,
+}
+
+/// One path- and content-free secure configuration failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycConfigLoadError {
+ kind: MycConfigLoadErrorKind,
+}
+
+impl MycConfigLoadError {
+ const fn new(kind: MycConfigLoadErrorKind) -> Self {
+ Self { kind }
+ }
+
+ #[must_use]
+ pub const fn kind(self) -> MycConfigLoadErrorKind {
+ self.kind
+ }
+}
+
+impl fmt::Debug for MycConfigLoadError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycConfigLoadError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for MycConfigLoadError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ MycConfigLoadErrorKind::InvalidPath => "configuration path is invalid",
+ MycConfigLoadErrorKind::Missing => "configuration document is missing",
+ MycConfigLoadErrorKind::AlreadyExists => "configuration document already exists",
+ MycConfigLoadErrorKind::InsecureParent => "configuration parent is insecure",
+ MycConfigLoadErrorKind::InsecureArtifact => "configuration artifact is insecure",
+ MycConfigLoadErrorKind::TooLarge => "configuration document exceeds its size limit",
+ MycConfigLoadErrorKind::Io => "configuration storage failed",
+ MycConfigLoadErrorKind::InvalidDocument => "configuration document is invalid",
+ MycConfigLoadErrorKind::UnsupportedPlatform => {
+ "secure configuration storage is unsupported"
+ }
+ })
+ }
+}
+
+impl Error for MycConfigLoadError {}
+
+/// Loads one selected configuration through the governed descriptor boundary.
+pub fn load_myc_config_document(
+ runtime: &MycRuntimeContext,
+) -> Result<MycConfigDocumentV1, MycConfigLoadError> {
+ load_myc_config_document_at(runtime.selected_config_path(), profile(runtime))
+}
+
+/// Loads one absolute candidate document without changing the selected path.
+pub fn load_myc_config_candidate(
+ runtime: &MycRuntimeContext,
+ candidate: &Path,
+) -> Result<MycConfigDocumentV1, MycConfigLoadError> {
+ load_myc_config_document_at(candidate, profile(runtime))
+}
+
+/// Validates and creates the selected non-secret configuration exactly once.
+pub fn initialize_myc_config_document(
+ runtime: &MycRuntimeContext,
+ bytes: &[u8],
+) -> Result<MycConfigDocumentV1, MycConfigLoadError> {
+ if bytes.len() > MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES {
+ return Err(MycConfigLoadError::new(MycConfigLoadErrorKind::TooLarge));
+ }
+ let document = parse_myc_config_v1(bytes, profile(runtime)).map_err(map_document)?;
+ persist_create_new(runtime.selected_config_path(), bytes)?;
+ Ok(document)
+}
+
+fn profile(runtime: &MycRuntimeContext) -> MycConfigProfile {
+ match runtime.profile() {
+ crate::MycBootstrapProfileV1::RepoLocal => MycConfigProfile::RepoLocal,
+ crate::MycBootstrapProfileV1::ServiceHost | crate::MycBootstrapProfileV1::Interactive => {
+ MycConfigProfile::Production
+ }
+ }
+}
+
+fn map_document(_: MycConfigV1Error) -> MycConfigLoadError {
+ MycConfigLoadError::new(MycConfigLoadErrorKind::InvalidDocument)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn load_myc_config_document_at(
+ path: &Path,
+ profile: MycConfigProfile,
+) -> Result<MycConfigDocumentV1, MycConfigLoadError> {
+ let bytes = native::read_existing(path, MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES)?;
+ parse_myc_config_v1(&bytes, profile).map_err(map_document)
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn load_myc_config_document_at(
+ _path: &Path,
+ _profile: MycConfigProfile,
+) -> Result<MycConfigDocumentV1, MycConfigLoadError> {
+ Err(MycConfigLoadError::new(
+ MycConfigLoadErrorKind::UnsupportedPlatform,
+ ))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn persist_create_new(path: &Path, bytes: &[u8]) -> Result<(), MycConfigLoadError> {
+ native::persist_create_new(path, bytes)
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn persist_create_new(_path: &Path, _bytes: &[u8]) -> Result<(), MycConfigLoadError> {
+ Err(MycConfigLoadError::new(
+ MycConfigLoadErrorKind::UnsupportedPlatform,
+ ))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) fn read_secure_bounded_file(
+ path: &Path,
+ maximum: usize,
+) -> Result<Vec<u8>, MycConfigLoadError> {
+ native::read_existing(path, maximum)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod native {
+ use std::ffi::OsString;
+ use std::fs::File;
+ use std::io::{Read, Write};
+ use std::os::unix::ffi::OsStrExt;
+ use std::path::{Component, Path, PathBuf};
+
+ use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, open, openat, unlinkat};
+ use rustix::process::geteuid;
+
+ use super::{MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MycConfigLoadError, MycConfigLoadErrorKind};
+
+ #[derive(Clone, Copy, PartialEq, Eq)]
+ struct Identity {
+ device: u64,
+ inode: u64,
+ }
+
+ struct SelectedPath {
+ parent: PathBuf,
+ name: OsString,
+ }
+
+ impl SelectedPath {
+ fn parse(path: &Path) -> Result<Self, MycConfigLoadError> {
+ if !path.is_absolute()
+ || path.as_os_str().as_bytes().len() > 4_096
+ || path.components().any(|component| {
+ !matches!(component, Component::RootDir | Component::Normal(_))
+ })
+ {
+ return Err(error(MycConfigLoadErrorKind::InvalidPath));
+ }
+ let name = match path.components().next_back() {
+ Some(Component::Normal(name)) if !name.as_bytes().is_empty() => name.to_os_string(),
+ _ => return Err(error(MycConfigLoadErrorKind::InvalidPath)),
+ };
+ let parent = path
+ .parent()
+ .filter(|parent| parent.is_absolute())
+ .ok_or_else(|| error(MycConfigLoadErrorKind::InvalidPath))?;
+ Ok(Self {
+ parent: parent.to_path_buf(),
+ name,
+ })
+ }
+ }
+
+ pub(super) fn read_existing(
+ path: &Path,
+ maximum: usize,
+ ) -> Result<Vec<u8>, MycConfigLoadError> {
+ let selected = SelectedPath::parse(path)?;
+ let parent = open_parent(&selected.parent)?;
+ let parent_identity = directory_identity(&parent)?;
+ let descriptor = openat(
+ &parent,
+ &selected.name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .map_err(|source| {
+ error(if source == rustix::io::Errno::NOENT {
+ MycConfigLoadErrorKind::Missing
+ } else {
+ MycConfigLoadErrorKind::InsecureArtifact
+ })
+ })?;
+ let mut file = File::from(descriptor);
+ let status = fstat(&file).map_err(|_| error(MycConfigLoadErrorKind::InsecureArtifact))?;
+ let (identity, length) = file_identity(&status, maximum)?;
+ let mut bytes = Vec::with_capacity(length);
+ Read::by_ref(&mut file)
+ .take(u64::try_from(length).unwrap_or(u64::MAX).saturating_add(1))
+ .read_to_end(&mut bytes)
+ .map_err(|_| error(MycConfigLoadErrorKind::Io))?;
+ if bytes.len() != length {
+ return Err(error(MycConfigLoadErrorKind::InsecureArtifact));
+ }
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &file,
+ identity,
+ length,
+ maximum,
+ )?;
+ Ok(bytes)
+ }
+
+ pub(super) fn persist_create_new(path: &Path, bytes: &[u8]) -> Result<(), MycConfigLoadError> {
+ let selected = SelectedPath::parse(path)?;
+ let parent = open_parent(&selected.parent)?;
+ let parent_identity = directory_identity(&parent)?;
+ let descriptor = openat(
+ &parent,
+ &selected.name,
+ OFlags::WRONLY
+ | OFlags::CREATE
+ | OFlags::EXCL
+ | OFlags::NOFOLLOW
+ | OFlags::CLOEXEC
+ | OFlags::NONBLOCK,
+ Mode::RUSR | Mode::WUSR,
+ )
+ .map_err(|source| {
+ error(if source == rustix::io::Errno::EXIST {
+ MycConfigLoadErrorKind::AlreadyExists
+ } else {
+ MycConfigLoadErrorKind::Io
+ })
+ })?;
+ let mut file = File::from(descriptor);
+ let status = fstat(&file).map_err(|_| error(MycConfigLoadErrorKind::InsecureArtifact))?;
+ let identity = status_identity(&status)?;
+ let result = (|| {
+ fchmod(&file, Mode::RUSR | Mode::WUSR)
+ .map_err(|_| error(MycConfigLoadErrorKind::Io))?;
+ file.write_all(bytes)
+ .and_then(|()| file.sync_all())
+ .map_err(|_| error(MycConfigLoadErrorKind::Io))?;
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &file,
+ identity,
+ bytes.len(),
+ MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES,
+ )?;
+ parent
+ .sync_all()
+ .map_err(|_| error(MycConfigLoadErrorKind::Io))?;
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &file,
+ identity,
+ bytes.len(),
+ MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES,
+ )
+ })();
+ if result.is_err() {
+ cleanup(&parent, &selected.name, identity);
+ }
+ result
+ }
+
+ fn open_parent(path: &Path) -> Result<File, MycConfigLoadError> {
+ let mut components = path.components();
+ if !matches!(components.next(), Some(Component::RootDir)) {
+ return Err(error(MycConfigLoadErrorKind::InvalidPath));
+ }
+ let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
+ let mut parent = File::from(
+ open(Path::new("/"), flags, Mode::empty())
+ .map_err(|_| error(MycConfigLoadErrorKind::InsecureParent))?,
+ );
+ for component in components {
+ let Component::Normal(name) = component else {
+ return Err(error(MycConfigLoadErrorKind::InvalidPath));
+ };
+ parent = File::from(
+ openat(&parent, name, flags, Mode::empty())
+ .map_err(|_| error(MycConfigLoadErrorKind::InsecureParent))?,
+ );
+ }
+ directory_identity(&parent)?;
+ Ok(parent)
+ }
+
+ fn directory_identity(directory: &File) -> Result<Identity, MycConfigLoadError> {
+ let status = fstat(directory).map_err(|_| error(MycConfigLoadErrorKind::InsecureParent))?;
+ let mode = normalize_mode(status.st_mode);
+ if !FileType::from_raw_mode(status.st_mode).is_dir()
+ || status.st_uid != geteuid().as_raw()
+ || mode & 0o022 != 0
+ {
+ return Err(error(MycConfigLoadErrorKind::InsecureParent));
+ }
+ status_identity(&status)
+ }
+
+ fn file_identity(
+ status: &rustix::fs::Stat,
+ maximum: usize,
+ ) -> Result<(Identity, usize), MycConfigLoadError> {
+ let mode = normalize_mode(status.st_mode);
+ let length =
+ usize::try_from(status.st_size).map_err(|_| error(MycConfigLoadErrorKind::TooLarge))?;
+ if !FileType::from_raw_mode(status.st_mode).is_file()
+ || normalize_link_count(status.st_nlink) != 1
+ || status.st_uid != geteuid().as_raw()
+ || mode & 0o400 == 0
+ || mode & 0o022 != 0
+ {
+ return Err(error(MycConfigLoadErrorKind::InsecureArtifact));
+ }
+ if length > maximum {
+ return Err(error(MycConfigLoadErrorKind::TooLarge));
+ }
+ Ok((status_identity(status)?, length))
+ }
+
+ fn status_identity(status: &rustix::fs::Stat) -> Result<Identity, MycConfigLoadError> {
+ Ok(Identity {
+ device: normalize_device(status.st_dev)
+ .map_err(|_| error(MycConfigLoadErrorKind::InsecureArtifact))?,
+ inode: status.st_ino,
+ })
+ }
+
+ pub(super) fn normalize_mode<T: Into<u32>>(raw: T) -> u32 {
+ raw.into()
+ }
+
+ pub(super) fn normalize_link_count<T: Into<u64>>(raw: T) -> u64 {
+ raw.into()
+ }
+
+ pub(super) fn normalize_device<T: TryInto<u64>>(raw: T) -> Result<u64, T::Error> {
+ raw.try_into()
+ }
+
+ fn validate_current(
+ selected: &SelectedPath,
+ parent: &File,
+ parent_identity: Identity,
+ held: &File,
+ held_identity: Identity,
+ length: usize,
+ maximum: usize,
+ ) -> Result<(), MycConfigLoadError> {
+ if directory_identity(parent)? != parent_identity {
+ return Err(error(MycConfigLoadErrorKind::InsecureParent));
+ }
+ let current_parent = open_parent(&selected.parent)?;
+ if directory_identity(¤t_parent)? != parent_identity {
+ return Err(error(MycConfigLoadErrorKind::InsecureParent));
+ }
+ let held_status =
+ fstat(held).map_err(|_| error(MycConfigLoadErrorKind::InsecureArtifact))?;
+ let (current_held, current_length) = file_identity(&held_status, maximum)?;
+ if current_held != held_identity || current_length != length {
+ return Err(error(MycConfigLoadErrorKind::InsecureArtifact));
+ }
+ let current = File::from(
+ openat(
+ ¤t_parent,
+ &selected.name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .map_err(|_| error(MycConfigLoadErrorKind::InsecureArtifact))?,
+ );
+ let status =
+ fstat(¤t).map_err(|_| error(MycConfigLoadErrorKind::InsecureArtifact))?;
+ let (current_identity, current_length) = file_identity(&status, maximum)?;
+ if current_identity != held_identity || current_length != length {
+ return Err(error(MycConfigLoadErrorKind::InsecureArtifact));
+ }
+ Ok(())
+ }
+
+ fn cleanup(parent: &File, name: &std::ffi::OsStr, identity: Identity) {
+ let current = openat(
+ parent,
+ name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .ok()
+ .map(File::from);
+ if current.as_ref().is_some_and(|file| {
+ fstat(file)
+ .ok()
+ .and_then(|status| status_identity(&status).ok())
+ == Some(identity)
+ }) {
+ let _ = unlinkat(parent, name, rustix::fs::AtFlags::empty());
+ let _ = parent.sync_all();
+ }
+ }
+
+ const fn error(kind: MycConfigLoadErrorKind) -> MycConfigLoadError {
+ MycConfigLoadError::new(kind)
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ use super::*;
+
+ #[test]
+ fn validation_rejects_a_replaced_parent_path() {
+ let root = tempfile::tempdir().expect("temporary root");
+ let parent_path = root.path().join("selected");
+ std::fs::create_dir(&parent_path).expect("selected parent");
+ std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o700))
+ .expect("secure selected parent");
+ let path = parent_path.join("config.toml");
+ std::fs::write(&path, b"config").expect("selected config");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
+ .expect("secure selected config");
+
+ let selected = SelectedPath::parse(&path).expect("selected path");
+ let parent = open_parent(&selected.parent).expect("held parent");
+ let parent_identity = directory_identity(&parent).expect("parent identity");
+ let held = File::from(
+ openat(
+ &parent,
+ &selected.name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .expect("held config"),
+ );
+ let status = fstat(&held).expect("held status");
+ let (identity, length) = file_identity(&status, 16).expect("held identity");
+
+ let moved = root.path().join("moved");
+ std::fs::rename(&parent_path, &moved).expect("move held parent");
+ std::fs::create_dir(&parent_path).expect("replacement parent");
+ std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o700))
+ .expect("secure replacement parent");
+ std::fs::write(parent_path.join("config.toml"), b"config").expect("replacement config");
+
+ assert_eq!(
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &held,
+ identity,
+ length,
+ 16,
+ )
+ .expect_err("parent replacement")
+ .kind(),
+ MycConfigLoadErrorKind::InsecureParent
+ );
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn errors_are_source_free_and_path_free() {
+ for kind in [
+ MycConfigLoadErrorKind::InvalidPath,
+ MycConfigLoadErrorKind::Missing,
+ MycConfigLoadErrorKind::AlreadyExists,
+ MycConfigLoadErrorKind::InsecureParent,
+ MycConfigLoadErrorKind::InsecureArtifact,
+ MycConfigLoadErrorKind::TooLarge,
+ MycConfigLoadErrorKind::Io,
+ MycConfigLoadErrorKind::InvalidDocument,
+ MycConfigLoadErrorKind::UnsupportedPlatform,
+ ] {
+ let error = MycConfigLoadError::new(kind);
+ assert_eq!(error.kind(), kind);
+ let rendered = format!("{error} {error:?}");
+ assert!(!rendered.contains('/'));
+ assert!(Error::source(&error).is_none());
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn native_create_read_permissions_and_collision_are_exact() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure directory");
+ let path = directory.path().join("config.toml");
+ let bytes = b"schema = \"radroots.myc.config\"\n";
+ native::persist_create_new(&path, bytes).expect("create-new config");
+ assert_eq!(
+ std::fs::metadata(&path)
+ .expect("metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o600
+ );
+ assert_eq!(
+ native::read_existing(&path, MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES).expect("secure read"),
+ bytes
+ );
+ assert_eq!(
+ native::persist_create_new(&path, bytes)
+ .expect_err("collision")
+ .kind(),
+ MycConfigLoadErrorKind::AlreadyExists
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn native_reader_rejects_insecure_parent_artifact_links_and_oversize() {
+ use std::os::unix::fs::{PermissionsExt as _, symlink};
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure directory");
+ let path = directory.path().join("config.toml");
+ std::fs::write(&path, b"config").expect("config");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o620))
+ .expect("insecure mode");
+ assert_eq!(
+ native::read_existing(&path, MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("group-write rejection")
+ .kind(),
+ MycConfigLoadErrorKind::InsecureArtifact
+ );
+
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
+ .expect("secure mode");
+ let hardlink = directory.path().join("hardlink.toml");
+ std::fs::hard_link(&path, &hardlink).expect("hard link");
+ assert_eq!(
+ native::read_existing(&path, MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("single-link rejection")
+ .kind(),
+ MycConfigLoadErrorKind::InsecureArtifact
+ );
+ std::fs::remove_file(&hardlink).expect("remove link");
+
+ let symlink_path = directory.path().join("symlink.toml");
+ symlink(&path, &symlink_path).expect("symlink");
+ assert_eq!(
+ native::read_existing(&symlink_path, MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("no-follow rejection")
+ .kind(),
+ MycConfigLoadErrorKind::InsecureArtifact
+ );
+
+ std::fs::write(&path, vec![b'x'; MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1])
+ .expect("oversize");
+ assert_eq!(
+ native::read_existing(&path, MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("oversize rejection")
+ .kind(),
+ MycConfigLoadErrorKind::TooLarge
+ );
+
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o720))
+ .expect("insecure parent");
+ assert_eq!(
+ native::read_existing(&path, MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("parent rejection")
+ .kind(),
+ MycConfigLoadErrorKind::InsecureParent
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn native_metadata_normalization_preserves_width_and_signed_device_rejection() {
+ assert_eq!(native::normalize_mode(0o600_u16), 0o600);
+ assert_eq!(native::normalize_mode(0o700_u32), 0o700);
+ assert_eq!(native::normalize_link_count(1_u16), 1);
+ assert_eq!(native::normalize_link_count(1_u64), 1);
+ assert_eq!(native::normalize_device(7_i32), Ok(7));
+ assert!(native::normalize_device(-1_i32).is_err());
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn generic_secure_reader_enforces_the_callers_exact_bound() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure directory");
+ let path = directory.path().join("artifact");
+ std::fs::write(&path, b"four").expect("artifact");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
+ .expect("secure artifact");
+
+ assert_eq!(
+ read_secure_bounded_file(&path, 4).expect("exact bound"),
+ b"four"
+ );
+ assert_eq!(
+ read_secure_bounded_file(&path, 3)
+ .expect_err("just over bound")
+ .kind(),
+ MycConfigLoadErrorKind::TooLarge
+ );
+ }
+}
diff --git a/src/config_v1.rs b/src/config_v1.rs
@@ -14,6 +14,11 @@ use crate::provider_contract::MycProviderContract;
const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json");
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) const fn config_schema_document() -> &'static str {
+ CONFIG_SCHEMA
+}
+
/// Exact schema identity for the production Myc configuration document.
pub const MYC_CONFIG_SCHEMA: &str = "radroots.myc.config";
@@ -155,6 +160,26 @@ pub struct MycConfigDocumentV1 {
normalized: Value,
effective: MycEffectiveConfigV1,
provider_contract: MycProviderContract,
+ runtime_thread_limits: MycRuntimeThreadLimitsV1,
+}
+
+/// Validated thread counts for the sole binary-owned Tokio runtime.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct MycRuntimeThreadLimitsV1 {
+ worker_threads: usize,
+ blocking_threads: usize,
+}
+
+impl MycRuntimeThreadLimitsV1 {
+ #[must_use]
+ pub const fn worker_threads(self) -> usize {
+ self.worker_threads
+ }
+
+ #[must_use]
+ pub const fn blocking_threads(self) -> usize {
+ self.blocking_threads
+ }
}
impl MycConfigDocumentV1 {
@@ -197,6 +222,12 @@ impl MycConfigDocumentV1 {
&self.provider_contract
}
+ /// Returns the validated limits for the sole binary-owned Tokio runtime.
+ #[must_use]
+ pub const fn runtime_thread_limits(&self) -> MycRuntimeThreadLimitsV1 {
+ self.runtime_thread_limits
+ }
+
pub(crate) const fn normalized(&self) -> &Value {
&self.normalized
}
@@ -246,11 +277,24 @@ pub fn parse_myc_config_v1(
let effective = build_effective(&normalized, &original)?;
let provider_contract = MycProviderContract::from_normalized(&normalized)
.map_err(|_| error(MycConfigV1ErrorKind::InvalidRelationship))?;
+ let runtime_thread_limits = MycRuntimeThreadLimitsV1 {
+ worker_threads: usize::try_from(integer(
+ &normalized,
+ "/resource_limits/runtime/worker_threads",
+ )?)
+ .map_err(|_| error(MycConfigV1ErrorKind::InvalidRelationship))?,
+ blocking_threads: usize::try_from(integer(
+ &normalized,
+ "/resource_limits/runtime/blocking_threads",
+ )?)
+ .map_err(|_| error(MycConfigV1ErrorKind::InvalidRelationship))?,
+ };
Ok(MycConfigDocumentV1 {
profile,
normalized,
effective,
provider_contract,
+ runtime_thread_limits,
})
}
@@ -509,6 +553,16 @@ const DEFAULTS: &[DefaultEntry] = &[
1_048_576,
MycConfigValueSource::RadrootsServiceHost,
),
+ default(
+ "/resource_limits/runtime/worker_threads",
+ 4,
+ MycConfigValueSource::EngineeringSafety,
+ ),
+ default(
+ "/resource_limits/runtime/blocking_threads",
+ 8,
+ MycConfigValueSource::EngineeringSafety,
+ ),
];
const fn default(path: &'static str, value: u64, source: MycConfigValueSource) -> DefaultEntry {
@@ -1281,7 +1335,7 @@ mod tests {
#[test]
fn defaults_have_exact_sources_and_explicit_values_override_them() {
- assert_eq!(DEFAULTS.len(), 39);
+ assert_eq!(DEFAULTS.len(), 41);
let mut table = EXAMPLE.parse::<toml::Table>().expect("example TOML");
for entry in DEFAULTS {
remove_toml_path(&mut table, entry.path);
diff --git a/src/lib.rs b/src/lib.rs
@@ -3,7 +3,10 @@
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod admin_v1;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod cli_bootstrap;
mod cli_v1;
+mod config_loader;
mod config_v1;
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod control_plane_wave_090_a;
@@ -20,6 +23,11 @@ mod nip46_wave_080_a;
mod nip46_wave_080_b;
mod nip46_work;
mod operations_v1;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod process_v1;
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+#[path = "process_v1_unsupported.rs"]
+mod process_v1;
mod provider_contract;
mod provider_credential;
mod provider_envelope;
@@ -46,6 +54,8 @@ mod state_repository;
mod state_request;
mod state_response;
mod status_v1;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod system_doctor;
mod transport_nostr_adapter;
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -58,14 +68,19 @@ pub use admin_v1::{
};
pub use cli_v1::{
MycBootstrapProfileV1, MycCliAdminOperationV1, MycCliExecutionPlanV1, MycCliInvocationV1,
- MycCliOfflineOperationV1, MycCliPrimaryAuthorityV1, MycCliV1Error, MycCliV1ErrorKind,
- MycCommandV1, MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1,
- parse_myc_cli_v1_from, plan_myc_cli_v1,
+ MycCliOfflineOperationV1, MycCliOutputModeV1, MycCliPrimaryAuthorityV1, MycCliV1Error,
+ MycCliV1ErrorKind, MycCommandV1, MycConfigApplyArgsV1, MycConfigCommandV1,
+ MycIdentityCommandArgsV1, MycIdentityCommandV1, MycStateBackupArgsV1, MycStateCommandV1,
+ MycStateRestoreArgsV1, parse_myc_cli_v1_from, plan_myc_cli_v1,
+};
+pub use config_loader::{
+ MycConfigLoadError, MycConfigLoadErrorKind, initialize_myc_config_document,
+ load_myc_config_candidate, load_myc_config_document,
};
pub use config_v1::{
MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION,
MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind,
- MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1,
+ MycConfigValueSource, MycEffectiveConfigV1, MycRuntimeThreadLimitsV1, parse_myc_config_v1,
};
pub use diagnostics_v1::{
MYC_DIAGNOSTICS_CONTRACT_VERSION, MYC_LOG_RECORD_MAX_UTF8_BYTES, MycLogEvent, MycLogLevel,
@@ -103,6 +118,7 @@ pub use operations_v1::{
MycBoundOperationsServer, MycOperationsCancellationToken, MycOperationsError,
MycOperationsErrorKind, MycOperationsServer,
};
+pub use process_v1::execute_myc_cli_v1;
pub use provider_contract::{
MYC_PROVIDER_CONCURRENCY_MAX, MYC_PROVIDER_CONTRACT_VERSION, MYC_PROVIDER_INPUT_MAX_BYTES,
MYC_PROVIDER_OUTPUT_MAX_BYTES, MYC_PROVIDER_REQUEST_DEADLINE_MAX_MS,
@@ -231,7 +247,8 @@ pub use state_governance::{
};
pub use state_host::{
MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state,
- open_myc_state_inspection, open_myc_state_read_write,
+ open_myc_state_inspection, open_myc_state_inspection_from_config, open_myc_state_read_write,
+ open_myc_state_read_write_from_config,
};
pub use state_maintenance::{
MycStagedStateRestore, MycStateMaintenanceError, MycStateMaintenanceErrorKind,
diff --git a/src/main.rs b/src/main.rs
@@ -7,8 +7,7 @@ use myc::{MycLogRecord, MycProcessResult};
fn main() -> ExitCode {
match myc::parse_myc_cli_v1_from(std::env::args_os()) {
Ok(invocation) => {
- let _plan = myc::plan_myc_cli_v1(&invocation);
- let result = MycProcessResult::ServiceOrDependencyUnavailable;
+ let result = myc::execute_myc_cli_v1(invocation);
eprintln!("{}", MycLogRecord::process_result(result));
result.exit_code()
}
diff --git a/src/process_v1.rs b/src/process_v1.rs
@@ -0,0 +1,946 @@
+//! Binary-owned execution for one already-admitted command invocation.
+
+use std::env;
+use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
+
+use radroots_service_host::{
+ AdminClient, AdminClientErrorKind, AdminClientTarget, ContractVersions, EntropySource,
+ SystemEntropy, SystemWallClock, WallClock,
+};
+use radroots_service_sqlite::{
+ BACKUP_MANIFEST_CANONICAL_MAX_BYTES, BackupCreatedAtUnixMs, IntegrityCheckOutcome,
+ IntegrityCheckedAtUnixMs, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
+ ServiceBackupManifest, WriterAuthority,
+};
+use radroots_storage::event::SourceGeneration;
+use serde_json::{Value, json};
+
+use crate::admin_v1::{admin_transport_limits, admit_admin_response_value};
+use crate::cli_bootstrap::read_identity_provisioning_document;
+use crate::config_v1::config_schema_document;
+use crate::{
+ MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION,
+ MYC_OPERATOR_CONTRACT_VERSION, MYC_PROVIDER_CONTRACT_VERSION,
+ MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_SCHEMA_VERSION, MycBootstrapProfileV1,
+ MycCliInvocationV1, MycCliOutputModeV1, MycCliPrimaryAuthorityV1, MycCommandV1,
+ MycConfigCommandV1, MycConfigDocumentV1, MycIdentityCommandArgsV1, MycIdentityCommandV1,
+ MycLocalSignerClient, MycProcessResult, MycProviderKind, MycProviderRole, MycRuntimeContext,
+ MycStateBackupArgsV1, MycStateCommandV1, MycStateMetadata, MycStateRestoreArgsV1,
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, finalize_myc_state_restore,
+ initialize_myc_config_document, initialize_myc_state, load_myc_config_candidate,
+ load_myc_config_document, open_myc_encrypted_identity, open_myc_state_inspection_from_config,
+ open_myc_state_read_write_from_config, plan_myc_cli_v1, provision_myc_encrypted_identity,
+ resolve_myc_runtime_context, resolve_myc_wrapping_credential, stage_myc_state_restore,
+ verify_myc_state_backup,
+};
+
+#[derive(Clone, Copy)]
+struct ProcessFailure(MycProcessResult);
+
+type ProcessResult<T> = Result<T, ProcessFailure>;
+
+/// Executes one admitted Myc invocation without reparsing process arguments.
+///
+/// Result bytes are written to stdout only after the governed operation
+/// succeeds. The binary retains responsibility for emitting the final safe
+/// structured diagnostic and choosing the process exit code.
+#[must_use]
+pub fn execute_myc_cli_v1(invocation: MycCliInvocationV1) -> MycProcessResult {
+ execute(invocation).unwrap_or_else(|failure| failure.0)
+}
+
+fn execute(invocation: MycCliInvocationV1) -> ProcessResult<MycProcessResult> {
+ let plan = plan_myc_cli_v1(&invocation);
+ if matches!(
+ (plan.primary_authority(), invocation.command()),
+ (MycCliPrimaryAuthorityV1::Daemon, MycCommandV1::Run)
+ ) {
+ return Err(ProcessFailure(
+ MycProcessResult::ServiceOrDependencyUnavailable,
+ ));
+ }
+
+ let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), host_environment());
+ let runtime =
+ resolve_myc_runtime_context(&resolver, &invocation).map_err(|_| input_failure())?;
+ let output = invocation.output_mode();
+
+ match (plan.primary_authority(), invocation.command()) {
+ (MycCliPrimaryAuthorityV1::Offline, MycCommandV1::Config(command)) => {
+ execute_config(output, &runtime, command)
+ }
+ (
+ MycCliPrimaryAuthorityV1::Offline | MycCliPrimaryAuthorityV1::LiveUnixAdmin,
+ MycCommandV1::State(command),
+ ) => execute_state(output, &runtime, command),
+ (
+ MycCliPrimaryAuthorityV1::Offline | MycCliPrimaryAuthorityV1::LiveUnixAdmin,
+ MycCommandV1::Identity(command),
+ ) => execute_identity(output, &runtime, command),
+ (MycCliPrimaryAuthorityV1::LiveUnixAdmin, MycCommandV1::Status) => {
+ execute_live_or_offline_status(output, &runtime)
+ }
+ (MycCliPrimaryAuthorityV1::Offline, MycCommandV1::Doctor) => {
+ execute_doctor(output, &runtime)
+ }
+ _ => Err(ProcessFailure(MycProcessResult::UnexpectedInternal)),
+ }
+}
+
+fn execute_doctor(
+ _output: MycCliOutputModeV1,
+ runtime: &MycRuntimeContext,
+) -> ProcessResult<MycProcessResult> {
+ let configuration = load_myc_config_document(runtime).map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ let report = tokio
+ .block_on(crate::run_myc_doctor(
+ runtime,
+ &crate::system_doctor::MycSystemDoctorProbe::new(runtime, &configuration),
+ ))
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ emit_bytes(report.canonical_json())?;
+ if report.exit_code() == 0 {
+ Ok(MycProcessResult::Success)
+ } else {
+ Err(ProcessFailure(MycProcessResult::DoctorRequiredCheckFailed))
+ }
+}
+
+fn execute_config(
+ output: MycCliOutputModeV1,
+ runtime: &MycRuntimeContext,
+ command: &MycConfigCommandV1,
+) -> ProcessResult<MycProcessResult> {
+ match command {
+ MycConfigCommandV1::Init => {
+ let bytes = read_bounded_stdin(MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES)?;
+ initialize_myc_config_document(runtime, &bytes).map_err(|_| input_failure())?;
+ emit_simple_success(output, "config_initialized")
+ }
+ MycConfigCommandV1::Validate => {
+ load_myc_config_document(runtime).map_err(|_| input_failure())?;
+ emit_simple_success(output, "config_valid")
+ }
+ MycConfigCommandV1::Show => {
+ let configuration = load_myc_config_document(runtime).map_err(|_| input_failure())?;
+ emit_bytes(configuration.effective().canonical_json().as_bytes())
+ }
+ MycConfigCommandV1::Schema => emit_bytes(config_schema_document().as_bytes()),
+ MycConfigCommandV1::Apply(arguments) => {
+ let current = load_myc_config_document(runtime).map_err(|_| input_failure())?;
+ let candidate = load_myc_config_candidate(runtime, arguments.candidate_config())
+ .map_err(|_| input_failure())?;
+ let build = migration_build_identity()?;
+ let applied_at = migration_time()?;
+ let tokio = build_tokio_runtime(current.runtime_thread_limits())?;
+ let outcome = tokio.block_on(async {
+ validate_candidate_providers(runtime, &candidate).await?;
+ let state =
+ open_myc_state_read_write_from_config(runtime, ¤t, applied_at, &build)
+ .await
+ .map_err(|_| state_failure())?;
+ let applied = state
+ .repository()
+ .apply_configuration(¤t, &candidate, applied_at, &build)
+ .await
+ .map_err(|error| match error.kind() {
+ crate::MycConfigApplyErrorKind::PolicyConflict
+ | crate::MycConfigApplyErrorKind::ResourceExhausted => conflict_failure(),
+ _ => state_failure(),
+ });
+ let closed = state.close().await.map_err(|_| state_failure());
+ closed.and(applied)
+ })?;
+ emit_value(
+ output,
+ "config_applied",
+ json!({
+ "generation": outcome.generation(),
+ "revoked_challenges": outcome.revoked_challenge_count(),
+ "revoked_connections": outcome.revoked_connection_count(),
+ }),
+ )
+ }
+ }
+}
+
+fn execute_state(
+ output: MycCliOutputModeV1,
+ runtime: &MycRuntimeContext,
+ command: &MycStateCommandV1,
+) -> ProcessResult<MycProcessResult> {
+ let configuration = load_myc_config_document(runtime).map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ match command {
+ MycStateCommandV1::Init => {
+ let generation = source_generation()?;
+ let created_at = wall_time_millis()?;
+ let metadata = MycStateMetadata::new(runtime, &configuration, generation, created_at)
+ .map_err(|_| state_failure())?;
+ let applied_at = migration_time()?;
+ let build = migration_build_identity()?;
+ tokio
+ .block_on(initialize_myc_state(runtime, &metadata, applied_at, &build))
+ .map_err(|_| state_failure())?;
+ emit_simple_success(output, "state_initialized")
+ }
+ MycStateCommandV1::Status => {
+ if let Some(bytes) = tokio.block_on(live_get(
+ runtime,
+ &configuration,
+ crate::MycAdminRoute::StateStatus,
+ None,
+ ))? {
+ return emit_bytes(&bytes);
+ }
+ let value = tokio.block_on(offline_state_status(runtime, &configuration))?;
+ emit_admin_value(output, crate::MycAdminRoute::StateStatus, value)
+ }
+ MycStateCommandV1::Backup(arguments) => {
+ if let Some(bytes) = tokio.block_on(live_backup(runtime, &configuration, arguments))? {
+ return emit_bytes(&bytes);
+ }
+ let manifest = tokio.block_on(offline_backup(runtime, &configuration, arguments))?;
+ emit_exact_bytes(manifest.canonical_bytes())
+ }
+ MycStateCommandV1::Restore(arguments) => {
+ tokio.block_on(offline_restore(runtime, &configuration, arguments))?;
+ emit_simple_success(output, "state_restore_finalized")
+ }
+ MycStateCommandV1::Verify => {
+ tokio.block_on(offline_verify(runtime, &configuration))?;
+ emit_simple_success(output, "state_verified")
+ }
+ MycStateCommandV1::Migrate => {
+ let build = migration_build_identity()?;
+ let applied_at = migration_time()?;
+ tokio.block_on(async {
+ let state = open_myc_state_read_write_from_config(
+ runtime,
+ &configuration,
+ applied_at,
+ &build,
+ )
+ .await
+ .map_err(|_| state_failure())?;
+ state.close().await.map_err(|_| state_failure())
+ })?;
+ emit_simple_success(output, "state_migrated")
+ }
+ }
+}
+
+fn execute_identity(
+ output: MycCliOutputModeV1,
+ runtime: &MycRuntimeContext,
+ command: &MycIdentityCommandV1,
+) -> ProcessResult<MycProcessResult> {
+ let configuration = load_myc_config_document(runtime).map_err(|_| input_failure())?;
+ match command {
+ MycIdentityCommandV1::Init(arguments) => {
+ let binding = identity_binding(&configuration, *arguments)?;
+ if binding.kind() != MycProviderKind::EncryptedFile {
+ return Err(conflict_failure());
+ }
+ let material = read_identity_provisioning_document(std::io::stdin().lock())
+ .map_err(|_| input_failure())?;
+ let credential =
+ resolve_myc_wrapping_credential(runtime, binding).map_err(|_| state_failure())?;
+ let paths = crate::state_host::state_paths(runtime).map_err(|_| state_failure())?;
+ let mut authority = WriterAuthority::acquire(&paths, OpenMode::Initialize)
+ .map_err(|_| state_failure())?
+ .ok_or_else(state_failure)?;
+ let identity = provision_myc_encrypted_identity(binding, &credential, material)
+ .map_err(|_| state_failure());
+ let released = authority.release().map_err(|_| state_failure());
+ let identity = released.and(identity)?;
+ emit_identity(
+ output,
+ arguments.role(),
+ identity.public_identity().as_hex(),
+ 0,
+ )
+ }
+ MycIdentityCommandV1::Status(arguments) | MycIdentityCommandV1::ExportPublic(arguments) => {
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ let route = match command {
+ MycIdentityCommandV1::Status(_) => crate::MycAdminRoute::IdentityStatus,
+ MycIdentityCommandV1::ExportPublic(_) => crate::MycAdminRoute::IdentityPublic,
+ MycIdentityCommandV1::Init(_) => {
+ return Err(ProcessFailure(MycProcessResult::UnexpectedInternal));
+ }
+ };
+ if let Some(bytes) = tokio.block_on(live_get(
+ runtime,
+ &configuration,
+ route,
+ Some(arguments.role()),
+ ))? {
+ return emit_bytes(&bytes);
+ }
+ let (public_key, generation, provider, available) =
+ tokio.block_on(offline_identity(runtime, &configuration, arguments.role()))?;
+ if matches!(command, MycIdentityCommandV1::ExportPublic(_)) {
+ let public_key = public_key.ok_or_else(state_failure)?;
+ emit_identity(output, arguments.role(), &public_key, generation)
+ } else {
+ emit_admin_value(
+ output,
+ crate::MycAdminRoute::IdentityStatus,
+ json!({
+ "available": available,
+ "configured": true,
+ "generation": generation,
+ "provider": provider.as_str(),
+ "public_key": public_key,
+ "reason_codes": if available { json!([]) } else { json!(["provider_unavailable"]) },
+ "role": arguments.role().as_str(),
+ }),
+ )
+ }
+ }
+ }
+}
+
+fn execute_live_or_offline_status(
+ output: MycCliOutputModeV1,
+ runtime: &MycRuntimeContext,
+) -> ProcessResult<MycProcessResult> {
+ let configuration = load_myc_config_document(runtime).map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ if let Some(bytes) = tokio.block_on(live_get(
+ runtime,
+ &configuration,
+ crate::MycAdminRoute::Status,
+ None,
+ ))? {
+ return emit_bytes(&bytes);
+ }
+ let state = tokio.block_on(offline_service_status(runtime, &configuration))?;
+ emit_admin_value(output, crate::MycAdminRoute::Status, state)
+}
+
+async fn live_get(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+ route: crate::MycAdminRoute,
+ role: Option<MycProviderRole>,
+) -> ProcessResult<Option<Box<[u8]>>> {
+ let client = AdminClient::new(
+ runtime.artifacts().admin_socket(),
+ admin_transport_limits(configuration).map_err(|_| input_failure())?,
+ )
+ .map_err(|_| input_failure())?;
+ let target = if let Some(role) = role {
+ AdminClientTarget::new(format!("{}?role={}", route.path(), role.as_str()))
+ } else {
+ AdminClientTarget::new(route.path())
+ }
+ .map_err(|_| input_failure())?;
+ match client.get::<Value>(&target).await {
+ Ok(response) => admit_admin_response_value(route, response.result())
+ .map(Some)
+ .map_err(|_| ProcessFailure(MycProcessResult::ServiceOrDependencyUnavailable)),
+ Err(error) if error.kind() == AdminClientErrorKind::Connect => Ok(None),
+ Err(error) if error.kind() == AdminClientErrorKind::ServerFailure => {
+ Err(conflict_failure())
+ }
+ Err(_) => Err(ProcessFailure(
+ MycProcessResult::ServiceOrDependencyUnavailable,
+ )),
+ }
+}
+
+async fn live_backup(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+ arguments: &MycStateBackupArgsV1,
+) -> ProcessResult<Option<Box<[u8]>>> {
+ let client = AdminClient::new(
+ runtime.artifacts().admin_socket(),
+ admin_transport_limits(configuration).map_err(|_| input_failure())?,
+ )
+ .map_err(|_| input_failure())?;
+ let target = AdminClientTarget::new(crate::MycAdminRoute::StateBackup.path())
+ .map_err(|_| input_failure())?;
+ let target_path = arguments.target().to_str().ok_or_else(input_failure)?;
+ let request = json!({
+ "confirmation": "confirm",
+ "expected_generation": arguments.expected_generation(),
+ "target_path": target_path,
+ });
+ let operation_id = radroots_service_host::AdminOperationId::new(arguments.operation_id())
+ .map_err(|_| input_failure())?;
+ match client
+ .mutate::<_, Value>(&target, operation_id, None, request)
+ .await
+ {
+ Ok(response) => {
+ admit_admin_response_value(crate::MycAdminRoute::StateBackup, response.result())
+ .map(Some)
+ .map_err(|_| ProcessFailure(MycProcessResult::ServiceOrDependencyUnavailable))
+ }
+ Err(error) if error.kind() == AdminClientErrorKind::Connect => Ok(None),
+ Err(error) if error.kind() == AdminClientErrorKind::ServerFailure => {
+ Err(conflict_failure())
+ }
+ Err(_) => Err(ProcessFailure(
+ MycProcessResult::ServiceOrDependencyUnavailable,
+ )),
+ }
+}
+
+async fn offline_state_status(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+) -> ProcessResult<Value> {
+ let state = open_myc_state_inspection_from_config(runtime, configuration)
+ .await
+ .map_err(|_| state_failure())?;
+ let inspected = async {
+ let generation = state
+ .repository()
+ .current_configuration_generation()
+ .await
+ .map_err(|_| state_failure())?;
+ let checked_at = integrity_time()?;
+ let report = state
+ .inspect_integrity(checked_at)
+ .await
+ .map_err(|_| state_failure())?;
+ let schema = state
+ .metadata()
+ .database_identity()
+ .supported_state_schema_version()
+ .get();
+ let verified = report.sqlite() == IntegrityCheckOutcome::Verified
+ && report.foreign_keys() == IntegrityCheckOutcome::Verified;
+ Ok(json!({
+ "backup_eligible": verified,
+ "generation": generation,
+ "integrity": if verified { "verified" } else { "failed" },
+ "reason_codes": if verified { json!([]) } else { json!(["database_integrity_failed"]) },
+ "schema_version": schema,
+ "writer_lock": "free",
+ }))
+ }
+ .await;
+ let closed = state.close().await.map_err(|_| state_failure());
+ closed.and(inspected)
+}
+
+async fn offline_service_status(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+) -> ProcessResult<Value> {
+ let state = offline_state_status(runtime, configuration).await?;
+ let generation = state
+ .get("generation")
+ .and_then(Value::as_u64)
+ .ok_or(ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ let schema_version = state
+ .get("schema_version")
+ .and_then(Value::as_u64)
+ .ok_or(ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ let integrity = state
+ .get("integrity")
+ .and_then(Value::as_str)
+ .ok_or(ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ let build = build_info()?;
+ let versions = build.contract_versions();
+ let digest = crate::state_metadata::normalized_config_digest(
+ configuration.profile(),
+ configuration.normalized(),
+ )
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ let discovery_configured = configuration
+ .provider_contract()
+ .binding(MycProviderRole::Discovery)
+ .is_some();
+ let unavailable = |configured: bool| {
+ json!({
+ "available": false,
+ "configured": configured,
+ "reason_codes": ["signer_provider_unavailable"],
+ })
+ };
+ Ok(json!({
+ "build_info": {
+ "contract_versions": {
+ "admin": versions.admin(),
+ "config": versions.config(),
+ "provider": versions.provider(),
+ "state": versions.state(),
+ "status": versions.status(),
+ },
+ "revision": build.service_commit(),
+ "toolchain": build.rust_version(),
+ "version": build.service_version(),
+ },
+ "configuration": {
+ "digest": hex::encode(digest.as_bytes()),
+ "schema": MYC_CONFIG_SCHEMA,
+ "schema_version": MYC_CONFIG_SCHEMA_VERSION,
+ "source": if runtime.profile() == MycBootstrapProfileV1::RepoLocal {
+ "derived_repo_local"
+ } else {
+ "explicit_config"
+ },
+ },
+ "contract_version": MYC_SIGNER_STATUS_CONTRACT_VERSION,
+ "instance": runtime.context().instance().as_str(),
+ "myc": {
+ "connection_counts": {},
+ "discovery": unavailable(discovery_configured),
+ "outbox": {"pending": 0, "unknown": 0},
+ "transport": unavailable(true),
+ "user": unavailable(true),
+ },
+ "persistence": {
+ "generation": generation,
+ "health": "read_only",
+ "integrity": integrity,
+ "reason_codes": [],
+ "schema_version": schema_version,
+ },
+ "phase": "unready",
+ "provider": {
+ "discovery": unavailable(discovery_configured),
+ "health": "unavailable",
+ "reason_codes": ["signer_provider_unavailable"],
+ "transport": unavailable(true),
+ "user": unavailable(true),
+ },
+ "ready": false,
+ "reason_codes": [
+ "required_relay_unavailable",
+ "signer_provider_unavailable",
+ "subscriber_not_active"
+ ],
+ "service": "myc",
+ "transport": {
+ "connected_relay_count": 0,
+ "health": "unavailable",
+ "reason_codes": ["required_relay_unavailable", "subscriber_not_active"],
+ "required_relays_ready": false,
+ },
+ "uptime_millis": 0,
+ }))
+}
+
+async fn offline_backup(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+ arguments: &MycStateBackupArgsV1,
+) -> ProcessResult<radroots_service_sqlite::ServiceBackupManifest> {
+ let build = migration_build_identity()?;
+ let applied_at = migration_time()?;
+ let state = open_myc_state_read_write_from_config(runtime, configuration, applied_at, &build)
+ .await
+ .map_err(|_| state_failure())?;
+ let captured = async {
+ let generation = state
+ .repository()
+ .current_configuration_generation()
+ .await
+ .map_err(|_| state_failure())?;
+ if u64::from(generation) != arguments.expected_generation() {
+ return Err(conflict_failure());
+ }
+ let created_at = backup_time()?;
+ state
+ .capture_online_backup(arguments.target(), created_at)
+ .await
+ .map_err(|_| state_failure())
+ }
+ .await;
+ let closed = state.close().await.map_err(|_| state_failure());
+ closed.and(captured)
+}
+
+async fn offline_restore(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+ arguments: &MycStateRestoreArgsV1,
+) -> ProcessResult<()> {
+ let manifest = read_bounded_file(arguments.manifest(), BACKUP_MANIFEST_CANONICAL_MAX_BYTES)?;
+ let parsed =
+ ServiceBackupManifest::from_canonical_bytes(&manifest).map_err(|_| state_failure())?;
+ if parsed.digest() != arguments.manifest_sha256() {
+ return Err(state_failure());
+ }
+ let expected = MycStateMetadata::new(
+ runtime,
+ configuration,
+ parsed.source_generation(),
+ parsed.created_at_unix_ms().get(),
+ )
+ .map_err(|_| state_failure())?;
+ let verified = verify_myc_state_backup(
+ &manifest,
+ arguments.manifest_sha256(),
+ arguments.bundle(),
+ &expected,
+ arguments.maximum_state_bytes(),
+ )
+ .map_err(|_| state_failure())?;
+ let staged = stage_myc_state_restore(runtime, &expected, verified)
+ .await
+ .map_err(|_| state_failure())?;
+ finalize_myc_state_restore(staged)
+ .await
+ .map_err(|_| state_failure())
+}
+
+async fn offline_verify(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+) -> ProcessResult<()> {
+ let build = migration_build_identity()?;
+ let applied_at = migration_time()?;
+ let state = open_myc_state_read_write_from_config(runtime, configuration, applied_at, &build)
+ .await
+ .map_err(|_| state_failure())?;
+ let report = state
+ .inspect_integrity(integrity_time()?)
+ .await
+ .map_err(|_| state_failure());
+ let closed = state.close().await.map_err(|_| state_failure());
+ match closed.and(report) {
+ Ok(report)
+ if report.sqlite() == IntegrityCheckOutcome::Verified
+ && report.foreign_keys() == IntegrityCheckOutcome::Verified =>
+ {
+ Ok(())
+ }
+ Err(error) => Err(error),
+ _ => Err(state_failure()),
+ }
+}
+
+async fn offline_identity(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+ role: MycProviderRole,
+) -> ProcessResult<(Option<String>, u16, MycProviderKind, bool)> {
+ let state = open_myc_state_inspection_from_config(runtime, configuration)
+ .await
+ .map_err(|_| state_failure())?;
+ let generation = state
+ .repository()
+ .current_configuration_generation()
+ .await
+ .map_err(|_| state_failure());
+ let closed = state.close().await.map_err(|_| state_failure());
+ let generation = closed.and(generation)?;
+ let binding = configuration
+ .provider_contract()
+ .binding(role)
+ .ok_or_else(input_failure)?;
+ match binding.kind() {
+ MycProviderKind::EncryptedFile => {
+ let credential =
+ resolve_myc_wrapping_credential(runtime, binding).map_err(|_| state_failure())?;
+ let identity =
+ open_myc_encrypted_identity(binding, &credential).map_err(|_| state_failure())?;
+ Ok((
+ Some(identity.public_identity().as_hex().to_owned()),
+ generation,
+ binding.kind(),
+ true,
+ ))
+ }
+ MycProviderKind::LocalSigner => {
+ let _client = MycLocalSignerClient::new(binding).map_err(|_| state_failure())?;
+ Ok((None, generation, binding.kind(), false))
+ }
+ }
+}
+
+fn identity_binding(
+ configuration: &MycConfigDocumentV1,
+ arguments: MycIdentityCommandArgsV1,
+) -> ProcessResult<&crate::MycProviderBinding> {
+ configuration
+ .provider_contract()
+ .binding(arguments.role())
+ .ok_or_else(input_failure)
+}
+
+async fn validate_candidate_providers(
+ runtime: &MycRuntimeContext,
+ candidate: &MycConfigDocumentV1,
+) -> ProcessResult<()> {
+ let cancellation = crate::MycTaskCancellation::uncancelled();
+ let executor =
+ crate::provider_executor::MycProviderExecutor::open(runtime, candidate, &cancellation)
+ .await
+ .map_err(|_| state_failure())?;
+ executor
+ .probe_all(wall_time_millis()?, provider_probe_seed()?, &cancellation)
+ .await
+ .map_err(|_| state_failure())
+}
+
+fn provider_probe_seed() -> ProcessResult<[u8; 32]> {
+ let mut seed = [0_u8; 32];
+ SystemEntropy
+ .fill_bytes(&mut seed)
+ .map_err(|_| state_failure())?;
+ Ok(seed)
+}
+
+fn migration_build_identity() -> ProcessResult<MigrationBuildIdentity> {
+ let build = build_info()?;
+ let versions = build.contract_versions();
+ MigrationBuildIdentity::new(
+ build.service_version(),
+ build.service_commit(),
+ build.lib_revision(),
+ build.rust_version(),
+ build.target(),
+ build.feature_profile(),
+ versions.config(),
+ versions.state(),
+ versions.admin(),
+ versions.status(),
+ versions.provider(),
+ )
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn build_info() -> ProcessResult<radroots_service_host::BuildInfo> {
+ let versions = ContractVersions::new(
+ MYC_CONFIG_SCHEMA_VERSION,
+ MYC_STATE_SCHEMA_VERSION,
+ MYC_OPERATOR_CONTRACT_VERSION,
+ MYC_SIGNER_STATUS_CONTRACT_VERSION,
+ MYC_PROVIDER_CONTRACT_VERSION,
+ )
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ radroots_service_host::compile_time_build_info!(
+ feature_profile: "service-host",
+ contract_versions: versions,
+ )
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn source_generation() -> ProcessResult<SourceGeneration> {
+ for _ in 0..4 {
+ let mut bytes = [0_u8; 32];
+ SystemEntropy
+ .fill_bytes(&mut bytes)
+ .map_err(|_| state_failure())?;
+ if let Ok(generation) = SourceGeneration::new(bytes) {
+ return Ok(generation);
+ }
+ }
+ Err(state_failure())
+}
+
+fn wall_time_seconds() -> ProcessResult<u64> {
+ SystemWallClock
+ .now_utc()
+ .map(|time| time.get())
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn wall_time_millis() -> ProcessResult<u64> {
+ wall_time_seconds()?
+ .checked_mul(1_000)
+ .filter(|value| *value <= i64::MAX as u64)
+ .ok_or(ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn migration_time() -> ProcessResult<MigrationAppliedAtUnixSeconds> {
+ MigrationAppliedAtUnixSeconds::new(wall_time_seconds()?)
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn backup_time() -> ProcessResult<BackupCreatedAtUnixMs> {
+ BackupCreatedAtUnixMs::new(wall_time_millis()?)
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn integrity_time() -> ProcessResult<IntegrityCheckedAtUnixMs> {
+ IntegrityCheckedAtUnixMs::new(wall_time_millis()?)
+ .ok_or(ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn build_tokio_runtime(
+ limits: crate::MycRuntimeThreadLimitsV1,
+) -> ProcessResult<tokio::runtime::Runtime> {
+ tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(limits.worker_threads())
+ .max_blocking_threads(limits.blocking_threads())
+ .enable_all()
+ .build()
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))
+}
+
+fn host_environment() -> RadrootsHostEnvironment {
+ let path = |name| {
+ env::var_os(name)
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ };
+ RadrootsHostEnvironment {
+ home_dir: path("HOME"),
+ xdg_config_home: path("XDG_CONFIG_HOME"),
+ xdg_data_home: path("XDG_DATA_HOME"),
+ xdg_state_home: path("XDG_STATE_HOME"),
+ xdg_cache_home: path("XDG_CACHE_HOME"),
+ xdg_runtime_dir: path("XDG_RUNTIME_DIR"),
+ appdata_dir: path("APPDATA"),
+ localappdata_dir: path("LOCALAPPDATA"),
+ }
+}
+
+fn read_bounded_stdin(maximum: usize) -> ProcessResult<Vec<u8>> {
+ let mut reader = std::io::stdin().lock();
+ let mut bytes = Vec::with_capacity(maximum.min(64 * 1_024).saturating_add(1));
+ Read::by_ref(&mut reader)
+ .take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1))
+ .read_to_end(&mut bytes)
+ .map_err(|_| input_failure())?;
+ if bytes.len() > maximum {
+ return Err(input_failure());
+ }
+ Ok(bytes)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn read_bounded_file(path: &Path, maximum: usize) -> ProcessResult<Vec<u8>> {
+ crate::config_loader::read_secure_bounded_file(path, maximum).map_err(|_| state_failure())
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn read_bounded_file(_path: &Path, _maximum: usize) -> ProcessResult<Vec<u8>> {
+ Err(state_failure())
+}
+
+fn emit_identity(
+ output: MycCliOutputModeV1,
+ role: MycProviderRole,
+ public_key: &str,
+ generation: u16,
+) -> ProcessResult<MycProcessResult> {
+ emit_admin_value(
+ output,
+ crate::MycAdminRoute::IdentityPublic,
+ json!({
+ "generation": generation,
+ "public_key": public_key,
+ "role": role.as_str(),
+ }),
+ )
+}
+
+fn emit_simple_success(
+ output: MycCliOutputModeV1,
+ code: &'static str,
+) -> ProcessResult<MycProcessResult> {
+ emit_value(output, code, json!({"ok": true}))
+}
+
+fn emit_admin_value(
+ _output: MycCliOutputModeV1,
+ route: crate::MycAdminRoute,
+ value: Value,
+) -> ProcessResult<MycProcessResult> {
+ let bytes = admit_admin_response_value(route, &value)
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ emit_bytes(&bytes)
+}
+
+fn emit_value(
+ output: MycCliOutputModeV1,
+ code: &'static str,
+ value: Value,
+) -> ProcessResult<MycProcessResult> {
+ let bytes = match output {
+ MycCliOutputModeV1::Json => serde_json::to_vec(&value)
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?,
+ MycCliOutputModeV1::Human => {
+ if value.is_object() && value.as_object().is_some_and(|object| object.len() > 1) {
+ serde_json::to_vec(&value)
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?
+ } else {
+ code.as_bytes().to_vec()
+ }
+ }
+ };
+ emit_bytes(&bytes)
+}
+
+fn emit_bytes(bytes: &[u8]) -> ProcessResult<MycProcessResult> {
+ let mut stdout = std::io::stdout().lock();
+ stdout
+ .write_all(bytes)
+ .and_then(|()| {
+ if bytes.ends_with(b"\n") {
+ Ok(())
+ } else {
+ stdout.write_all(b"\n")
+ }
+ })
+ .and_then(|()| stdout.flush())
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ Ok(MycProcessResult::Success)
+}
+
+fn emit_exact_bytes(bytes: &[u8]) -> ProcessResult<MycProcessResult> {
+ let mut stdout = std::io::stdout().lock();
+ stdout
+ .write_all(bytes)
+ .and_then(|()| stdout.flush())
+ .map_err(|_| ProcessFailure(MycProcessResult::UnexpectedInternal))?;
+ Ok(MycProcessResult::Success)
+}
+
+const fn input_failure() -> ProcessFailure {
+ ProcessFailure(MycProcessResult::InputOrConfiguration)
+}
+
+const fn state_failure() -> ProcessFailure {
+ ProcessFailure(MycProcessResult::StateOrIdentityUnavailable)
+}
+
+const fn conflict_failure() -> ProcessFailure {
+ ProcessFailure(MycProcessResult::OperationRejectedOrConflict)
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn runtime_limits_are_used_without_cpu_derived_defaults() {
+ let source = include_str!("process_v1.rs")
+ .split("#[cfg(test)]")
+ .next()
+ .expect("production source");
+ assert_eq!(source.matches("Builder::new_multi_thread()").count(), 1);
+ assert!(!source.contains("available_parallelism"));
+ assert!(source.contains("worker_threads(limits.worker_threads())"));
+ assert!(source.contains("max_blocking_threads(limits.blocking_threads())"));
+ }
+
+ #[test]
+ fn host_environment_uses_only_standard_path_inputs() {
+ let source = include_str!("process_v1.rs");
+ assert!(!source.contains("path(\"MYC_"));
+ for name in [
+ "HOME",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+ "XDG_STATE_HOME",
+ "XDG_CACHE_HOME",
+ "XDG_RUNTIME_DIR",
+ "APPDATA",
+ "LOCALAPPDATA",
+ ] {
+ assert!(source.contains(&format!("path(\"{name}\")")));
+ }
+ }
+}
diff --git a/src/process_v1_unsupported.rs b/src/process_v1_unsupported.rs
@@ -0,0 +1,14 @@
+//! Fail-closed process execution on unsupported host platforms.
+
+use crate::{MycCliInvocationV1, MycCommandV1, MycProcessResult};
+
+/// Rejects execution without acquiring filesystem, database, socket, or
+/// provider authority on a platform without the governed native host surface.
+#[must_use]
+pub fn execute_myc_cli_v1(invocation: MycCliInvocationV1) -> MycProcessResult {
+ if matches!(invocation.command(), MycCommandV1::Run) {
+ MycProcessResult::ServiceOrDependencyUnavailable
+ } else {
+ MycProcessResult::InputOrConfiguration
+ }
+}
diff --git a/src/provider_executor.rs b/src/provider_executor.rs
@@ -12,16 +12,19 @@ use nostr::{
JsonUtil as _, Keys, PublicKey, SecretKey, UnsignedEvent,
nips::{nip04, nip44},
};
+use sha2::{Digest as _, Sha256};
use crate::provider_local_signer::{ProtectedWireHex, WireCapability, WireProviderResult};
use crate::provider_verification::verify_encrypted_provider_response;
use crate::{
MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION, MYC_PROVIDER_INPUT_MAX_BYTES, MycConfigDocumentV1,
MycDecryptedIdentity, MycLocalSignerClient, MycProviderBinding, MycProviderCapability,
- MycProviderKind, MycProviderOperation, MycProviderResponseObservedAtUnixMs, MycProviderRole,
- MycRuntimeContext, MycTaskCancellation, MycVerifiedProviderResponse,
- open_myc_encrypted_identity, resolve_myc_wrapping_credential,
+ MycProviderCorrelationId, MycProviderDeadlineUnixMs, MycProviderKind, MycProviderOperation,
+ MycProviderOperationId, MycProviderOperationInput, MycProviderResponseObservedAtUnixMs,
+ MycProviderRole, MycRuntimeContext, MycTaskCancellation, MycVerifiedProviderResponse,
};
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use crate::{open_myc_encrypted_identity, resolve_myc_wrapping_credential};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MycProviderExecutionErrorKind {
@@ -65,6 +68,42 @@ const fn execution_error(kind: MycProviderExecutionErrorKind) -> MycProviderExec
MycProviderExecutionError { kind }
}
+struct OwnedBlockingTask<T> {
+ handle: Option<tokio::task::JoinHandle<T>>,
+}
+
+impl<T: Send + 'static> OwnedBlockingTask<T> {
+ fn spawn(task: impl FnOnce() -> T + Send + 'static) -> Self {
+ Self {
+ handle: Some(tokio::task::spawn_blocking(task)),
+ }
+ }
+
+ async fn join(
+ &mut self,
+ failure: MycProviderExecutionErrorKind,
+ ) -> Result<T, MycProviderExecutionError> {
+ let result = match self.handle.as_mut() {
+ Some(handle) => handle.await,
+ None => return Err(execution_error(failure)),
+ };
+ self.handle.take();
+ result.map_err(|_| execution_error(failure))
+ }
+}
+
+impl<T> Drop for OwnedBlockingTask<T> {
+ fn drop(&mut self) {
+ let Some(handle) = self.handle.take() else {
+ return;
+ };
+ handle.abort();
+ while !handle.is_finished() {
+ std::thread::park_timeout(core::time::Duration::from_millis(1));
+ }
+ }
+}
+
enum ExecutableProvider {
EncryptedFile {
binding: MycProviderBinding,
@@ -117,7 +156,7 @@ impl MycProviderExecutor {
let runtime = runtime.clone();
let binding = binding.clone();
let worker_binding = binding.clone();
- let mut worker = tokio::task::spawn_blocking(move || {
+ let mut worker = OwnedBlockingTask::spawn(move || {
let credential =
resolve_myc_wrapping_credential(&runtime, &worker_binding)
.map_err(|_| ())?;
@@ -125,11 +164,10 @@ impl MycProviderExecutor {
.map_err(|_| ())
});
let identity = tokio::select! {
- result = &mut worker => result
- .map_err(|_| execution_error(MycProviderExecutionErrorKind::Open))?
+ result = worker.join(MycProviderExecutionErrorKind::Open) => result?
.map_err(|_| execution_error(MycProviderExecutionErrorKind::Open))?,
() = cancellation.cancelled() => {
- let _ = worker.await;
+ let _ = worker.join(MycProviderExecutionErrorKind::Open).await;
return Err(execution_error(MycProviderExecutionErrorKind::Cancelled));
}
};
@@ -175,14 +213,13 @@ impl MycProviderExecutor {
ExecutableProvider::EncryptedFile { binding, identity } => {
let binding = binding.clone();
let identity = Arc::clone(identity);
- let mut worker = tokio::task::spawn_blocking(move || {
+ let mut worker = OwnedBlockingTask::spawn(move || {
let result = execute_encrypted(&identity, &operation)?;
Ok::<_, MycProviderExecutionError>((operation, result))
});
tokio::select! {
- joined = &mut worker => {
- let (operation, result) = joined
- .map_err(|_| execution_error(MycProviderExecutionErrorKind::Operation))??;
+ joined = worker.join(MycProviderExecutionErrorKind::Operation) => {
+ let (operation, result) = joined??;
verify_encrypted_provider_response(&binding, &operation, observed_at, result)
.map_err(|_| execution_error(MycProviderExecutionErrorKind::Verification))
}
@@ -190,7 +227,7 @@ impl MycProviderExecutor {
// A blocking cryptographic call cannot be abandoned. Join it before
// returning cancellation so no protected operation is detached.
// The result is deliberately discarded and never becomes domain authority.
- let _ = worker.await;
+ let _ = worker.join(MycProviderExecutionErrorKind::Operation).await;
Err(execution_error(MycProviderExecutionErrorKind::Cancelled))
}
}
@@ -216,6 +253,66 @@ impl MycProviderExecutor {
.iter()
.any(|provider| provider.role() == role)
}
+
+ pub(crate) async fn probe_all(
+ &self,
+ observed_at_unix_ms: u64,
+ seed: [u8; 32],
+ cancellation: &MycTaskCancellation,
+ ) -> Result<(), MycProviderExecutionError> {
+ let observed_at = MycProviderResponseObservedAtUnixMs::new(observed_at_unix_ms)
+ .map_err(|_| execution_error(MycProviderExecutionErrorKind::Binding))?;
+ for (index, provider) in self.providers.iter().enumerate() {
+ let role = provider.role();
+ let binding = match provider {
+ ExecutableProvider::EncryptedFile { binding, .. }
+ | ExecutableProvider::LocalSigner { binding, .. } => binding,
+ };
+ let timeout = binding
+ .local_signer_limits()
+ .map_or(15_000, |limits| limits.request_deadline_ms());
+ let deadline = observed_at_unix_ms
+ .checked_add(timeout)
+ .and_then(|value| MycProviderDeadlineUnixMs::new(value).ok())
+ .ok_or_else(|| execution_error(MycProviderExecutionErrorKind::Binding))?;
+ let index = u32::try_from(index)
+ .map_err(|_| execution_error(MycProviderExecutionErrorKind::Binding))?;
+ let operation = MycProviderOperation::new(
+ binding,
+ MycProviderOperationId::from_bytes(probe_identifier(
+ b"operation",
+ &seed,
+ index,
+ role,
+ )),
+ MycProviderCorrelationId::from_bytes(probe_identifier(
+ b"correlation",
+ &seed,
+ index,
+ role,
+ )),
+ deadline,
+ MycProviderOperationInput::describe(),
+ )
+ .map_err(|_| execution_error(MycProviderExecutionErrorKind::Binding))?;
+ let response = self.execute(operation, observed_at, cancellation).await?;
+ if response.role() != role || response.capability() != MycProviderCapability::Describe {
+ return Err(execution_error(MycProviderExecutionErrorKind::Verification));
+ }
+ }
+ Ok(())
+ }
+}
+
+fn probe_identifier(kind: &[u8], seed: &[u8; 32], index: u32, role: MycProviderRole) -> [u8; 32] {
+ let mut hasher = Sha256::new();
+ hasher.update(b"radroots.myc.provider_probe.v1\0");
+ hasher.update(u64::try_from(kind.len()).unwrap_or(u64::MAX).to_be_bytes());
+ hasher.update(kind);
+ hasher.update(seed);
+ hasher.update(index.to_be_bytes());
+ hasher.update(role.as_str().as_bytes());
+ hasher.finalize().into()
}
impl fmt::Debug for MycProviderExecutor {
@@ -499,4 +596,60 @@ mod tests {
assert!(!format!("{error} {error:?}").contains("protected"));
}
}
+
+ #[test]
+ fn provider_probe_identifiers_are_domain_seed_index_and_role_bound() {
+ let seed = [7_u8; 32];
+ let operation = probe_identifier(b"operation", &seed, 0, MycProviderRole::Transport);
+ assert_eq!(
+ operation,
+ probe_identifier(b"operation", &seed, 0, MycProviderRole::Transport)
+ );
+ assert_ne!(
+ operation,
+ probe_identifier(b"correlation", &seed, 0, MycProviderRole::Transport)
+ );
+ assert_ne!(
+ operation,
+ probe_identifier(b"operation", &[8_u8; 32], 0, MycProviderRole::Transport)
+ );
+ assert_ne!(
+ operation,
+ probe_identifier(b"operation", &seed, 1, MycProviderRole::Transport)
+ );
+ assert_ne!(
+ operation,
+ probe_identifier(b"operation", &seed, 0, MycProviderRole::User)
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn dropping_blocking_provider_work_drains_it_before_returning() {
+ use std::sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ mpsc,
+ };
+
+ let (entered_tx, entered_rx) = mpsc::channel();
+ let (release_tx, release_rx) = mpsc::channel();
+ let completed = Arc::new(AtomicBool::new(false));
+ let worker_completed = Arc::clone(&completed);
+ let task = OwnedBlockingTask::spawn(move || {
+ entered_tx.send(()).expect("entered signal");
+ release_rx.recv().expect("release signal");
+ worker_completed.store(true, Ordering::SeqCst);
+ });
+ entered_rx.recv().expect("worker entered");
+ let releaser = std::thread::spawn(move || {
+ std::thread::sleep(core::time::Duration::from_millis(25));
+ release_tx.send(()).expect("release worker");
+ });
+
+ drop(task);
+
+ releaser.join().expect("releaser joined");
+ assert!(completed.load(Ordering::SeqCst));
+ }
}
diff --git a/src/provider_local_signer.rs b/src/provider_local_signer.rs
@@ -566,7 +566,6 @@ pub(crate) enum WireProviderResult {
pub(crate) struct ProtectedWireHex(Zeroizing<String>);
impl ProtectedWireHex {
- #[cfg(any(test, target_os = "linux", target_os = "macos"))]
pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
Self(Zeroizing::new(hex::encode(bytes)))
}
diff --git a/src/runtime_supervision.rs b/src/runtime_supervision.rs
@@ -32,6 +32,13 @@ pub struct MycTaskCancellation {
}
impl MycTaskCancellation {
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn uncancelled() -> Self {
+ Self {
+ inner: CancellationToken::new(),
+ }
+ }
+
/// Returns whether coordinated cancellation has already been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
@@ -43,7 +50,7 @@ impl MycTaskCancellation {
self.inner.cancelled().await;
}
- #[cfg(test)]
+ #[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
pub(crate) fn test_pair() -> (Self, CancellationToken) {
let token = CancellationToken::new();
(
diff --git a/src/state_config.rs b/src/state_config.rs
@@ -185,6 +185,22 @@ impl fmt::Debug for MycConfigApplyOutcome {
}
impl MycStateRepository<'_> {
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) async fn current_configuration_generation(
+ &self,
+ ) -> Result<u16, MycConfigApplyError> {
+ self.host()
+ .transaction(|transaction| {
+ Box::pin(async move {
+ read_latest_header(transaction)
+ .await
+ .map(|(generation, _)| generation)
+ })
+ })
+ .await
+ .map_err(map_apply_transaction_error)
+ }
+
/// Atomically applies one complete candidate configuration while offline.
///
/// The current document must match the latest durable binding. The candidate
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -7,17 +7,18 @@ use std::{
};
use radroots_service_sqlite::{
- BackupCreatedAtUnixMs, IntegrityCheckedAtUnixMs, MigrationApplicationOutcome,
- MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceBackupManifest,
- ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqliteIntegrityReport,
- ServiceSqlitePaths, initialize_database,
+ BackupCreatedAtUnixMs, ExistingServiceDatabaseIntent, IntegrityCheckedAtUnixMs,
+ MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode,
+ ServiceBackupManifest, ServiceSqliteApplicationId, ServiceSqliteConnectionOptions,
+ ServiceSqliteHost, ServiceSqliteIntegrityReport, ServiceSqlitePaths, initialize_database,
};
use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions};
use crate::{
- MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, MycRuntimeContext,
- MycStateMaintenanceError, MycStateMaintenanceErrorKind, MycStateMetadata, MycStateRepository,
- myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs,
+ MYC_STATE_APPLICATION_ID, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION,
+ MycConfigDocumentV1, MycRuntimeContext, MycStateMaintenanceError, MycStateMaintenanceErrorKind,
+ MycStateMetadata, MycStateRepository, myc_migration_catalog, myc_schema_catalog,
+ validate_myc_state_catalogs,
};
/// Stable lifecycle mode of one opened Myc state host.
@@ -252,12 +253,10 @@ pub async fn initialize_myc_state(
metadata: metadata.clone(),
};
if !exact_initialization_outcome(outcome) {
- let _ = state.close().await;
- return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog));
+ return Err(close_error(&state.host, MycStateHostErrorKind::Catalog).await);
}
if state.repository().bind_or_verify().await.is_err() {
- let _ = state.close().await;
- return Err(MycStateHostError::new(MycStateHostErrorKind::Repository));
+ return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
}
state
.close()
@@ -294,8 +293,7 @@ pub async fn open_myc_state_read_write(
.await
.map_err(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?;
if !exact_existing_outcome(outcome) {
- let _ = host.close().await;
- return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog));
+ return Err(close_error(&host, MycStateHostErrorKind::Catalog).await);
}
let state = MycStateHost {
host,
@@ -303,8 +301,58 @@ pub async fn open_myc_state_read_write(
metadata: metadata.clone(),
};
if state.repository().bind_or_verify().await.is_err() {
- let _ = state.close().await;
- return Err(MycStateHostError::new(MycStateHostErrorKind::Repository));
+ return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
+ }
+ Ok(state)
+}
+
+/// Opens existing state from a sealed intent and discovers actual source metadata.
+///
+/// The caller supplies configuration policy but no source generation or
+/// creation-time guess. Those values are discovered from the same retained
+/// authority that is returned in the host.
+pub async fn open_myc_state_read_write_from_config(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+) -> Result<MycStateHost, MycStateHostError> {
+ let paths = state_paths(runtime)?;
+ let (migrations, schema) = catalogs()?;
+ let intent = existing_intent(&paths)?;
+ let (opened, outcome) = ServiceSqliteHost::open_read_write_existing_with_intent(
+ &paths,
+ &intent,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ applied_at,
+ build,
+ &[],
+ )
+ .await
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?;
+ if !exact_existing_outcome(outcome) {
+ let (host, _) = opened.into_parts();
+ return Err(close_error(&host, MycStateHostErrorKind::Catalog).await);
+ }
+ let (host, actual) = opened.into_parts();
+ let metadata = match MycStateMetadata::from_existing_database(runtime, configuration, &actual) {
+ Ok(metadata) => metadata,
+ Err(_) => {
+ return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await);
+ }
+ };
+ if require_migration_build(&metadata, build).is_err() {
+ return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await);
+ }
+ let state = MycStateHost {
+ host,
+ mode: MycStateHostMode::ReadWriteExisting,
+ metadata,
+ };
+ if state.repository().bind_or_verify().await.is_err() {
+ return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
}
Ok(state)
}
@@ -333,12 +381,71 @@ pub async fn open_myc_state_inspection(
metadata: metadata.clone(),
};
if state.repository().verify_binding().await.is_err() {
- let _ = state.close().await;
- return Err(MycStateHostError::new(MycStateHostErrorKind::Repository));
+ return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
}
Ok(state)
}
+/// Opens existing inspection state from a sealed intent and actual metadata.
+pub async fn open_myc_state_inspection_from_config(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+) -> Result<MycStateHost, MycStateHostError> {
+ let paths = state_paths(runtime)?;
+ let (migrations, schema) = catalogs()?;
+ let intent = existing_intent(&paths)?;
+ let opened = ServiceSqliteHost::open_read_only_inspection_with_intent(
+ &paths,
+ &intent,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ )
+ .await
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?;
+ let (host, actual) = opened.into_parts();
+ let metadata = match MycStateMetadata::from_existing_database(runtime, configuration, &actual) {
+ Ok(metadata) => metadata,
+ Err(_) => {
+ return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await);
+ }
+ };
+ let state = MycStateHost {
+ host,
+ mode: MycStateHostMode::ReadOnlyInspection,
+ metadata,
+ };
+ if state.repository().verify_binding().await.is_err() {
+ return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await);
+ }
+ Ok(state)
+}
+
+async fn close_error(
+ host: &ServiceSqliteHost,
+ fallback: MycStateHostErrorKind,
+) -> MycStateHostError {
+ if host.close().await.is_err() {
+ MycStateHostError::new(MycStateHostErrorKind::Close)
+ } else {
+ MycStateHostError::new(fallback)
+ }
+}
+
+fn existing_intent(
+ paths: &ServiceSqlitePaths,
+) -> Result<ExistingServiceDatabaseIntent, MycStateHostError> {
+ let schema = core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION)
+ .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))?;
+ let application = ServiceSqliteApplicationId::new(MYC_STATE_APPLICATION_ID)
+ .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))?;
+ Ok(ExistingServiceDatabaseIntent::new(
+ paths,
+ schema,
+ application,
+ ))
+}
+
pub(crate) fn state_paths(
runtime: &MycRuntimeContext,
) -> Result<ServiceSqlitePaths, MycStateHostError> {
diff --git a/src/state_metadata.rs b/src/state_metadata.rs
@@ -259,6 +259,31 @@ impl MycStateMetadata {
})
}
+ pub(crate) fn from_existing_database(
+ runtime: &MycRuntimeContext,
+ configuration: &MycConfigDocumentV1,
+ actual: &ServiceDatabaseMetadata,
+ ) -> Result<Self, MycStateMetadataError> {
+ let expected_application = ServiceSqliteApplicationId::new(MYC_STATE_APPLICATION_ID)
+ .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?;
+ if actual.service() != runtime.context().service()
+ || actual.instance() != runtime.context().instance()
+ || actual.application_id() != expected_application
+ || actual.state_schema_version().get() < MYC_STATE_BASE_SCHEMA_VERSION
+ || actual.state_schema_version().get() > MYC_STATE_SCHEMA_VERSION
+ {
+ return Err(MycStateMetadataError::new(
+ MycStateMetadataErrorKind::Database,
+ ));
+ }
+ Self::new(
+ runtime,
+ configuration,
+ actual.source_generation(),
+ actual.created_at_unix_ms(),
+ )
+ }
+
/// Returns the immutable shared schema-v1 initialization metadata.
#[must_use]
pub const fn initial_database_metadata(&self) -> &ServiceDatabaseMetadata {
diff --git a/src/state_recovery.rs b/src/state_recovery.rs
@@ -212,6 +212,24 @@ impl fmt::Debug for MycDeliveryRecoveryReport {
}
impl MycStateRepository<'_> {
+ /// Verifies the bounded global outbox relationships without mutation.
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) async fn verify_delivery_invariants(&self) -> Result<(), MycStateRepositoryError> {
+ let expected = PersistedMetadata::from(self.expected());
+ let outbox_maximum = self.expected().outbox_maximum();
+ self.host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ require_expected_metadata(transaction, &expected)
+ .await
+ .map_err(RecoveryOperationError::from)?;
+ verify_global_invariants(transaction, outbox_maximum).await
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+ }
+
/// Recovers startup delivery state in fixed bounded transactions without relay I/O.
///
/// The later runtime owner must pause admission while invoking this method.
diff --git a/src/system_doctor.rs b/src/system_doctor.rs
@@ -0,0 +1,235 @@
+//! Production active-doctor probes composed from existing sealed authorities.
+
+use radroots_service_host::{EntropySource, SystemEntropy, SystemWallClock, WallClock};
+use radroots_service_sqlite::{
+ IntegrityCheckOutcome, IntegrityCheckedAtUnixMs, MinimumFreeBytes,
+ PlatformStateFilesystemCapacitySource, inspect_state_filesystem_capacity,
+};
+
+use crate::admin_v1::admin_transport_limits;
+use crate::provider_executor::MycProviderExecutor;
+use crate::transport_nostr_adapter::MycNostrDeliveryAdapter;
+use crate::{
+ MycConfigDocumentV1, MycDoctorCheckDefinition, MycDoctorCheckId, MycDoctorFuture,
+ MycDoctorObservation, MycDoctorProbe, MycRuntimeContext, MycTaskCancellation,
+ open_myc_state_inspection_from_config,
+};
+
+pub(crate) struct MycSystemDoctorProbe<'a> {
+ runtime: &'a MycRuntimeContext,
+ configuration: &'a MycConfigDocumentV1,
+}
+
+impl<'a> MycSystemDoctorProbe<'a> {
+ pub(crate) const fn new(
+ runtime: &'a MycRuntimeContext,
+ configuration: &'a MycConfigDocumentV1,
+ ) -> Self {
+ Self {
+ runtime,
+ configuration,
+ }
+ }
+
+ async fn run(&self, definition: MycDoctorCheckDefinition) -> bool {
+ match definition.id() {
+ MycDoctorCheckId::PathsPermissions => self.probe_paths(),
+ MycDoctorCheckId::WriterLock
+ | MycDoctorCheckId::SqliteSchema
+ | MycDoctorCheckId::SqliteIntegrity
+ | MycDoctorCheckId::OutboxInvariants => self.probe_state(definition.id()).await,
+ MycDoctorCheckId::SqliteFreeSpace => self.probe_free_space(),
+ MycDoctorCheckId::IdentityBinding => self.probe_identity_binding().await,
+ MycDoctorCheckId::SignerProvider => self.probe_signer_provider().await,
+ MycDoctorCheckId::AdminBindPolicy => self.probe_admin_policy(),
+ MycDoctorCheckId::OperationsBindPolicy => self.probe_operations_policy(),
+ MycDoctorCheckId::NetworkPolicy => {
+ MycNostrDeliveryAdapter::from_configuration(self.configuration).is_ok()
+ }
+ MycDoctorCheckId::RequiredRelays => {
+ let Some(deadline) = absolute_deadline(definition.deadline_ms()) else {
+ return false;
+ };
+ MycNostrDeliveryAdapter::probe_required_relays(self.configuration, deadline)
+ .await
+ .is_ok()
+ }
+ MycDoctorCheckId::ClockSkew => false,
+ }
+ }
+
+ fn probe_paths(&self) -> bool {
+ let Ok(paths) = crate::state_host::state_paths(self.runtime) else {
+ return false;
+ };
+ let Ok(minimum) = MinimumFreeBytes::new(1) else {
+ return false;
+ };
+ inspect_state_filesystem_capacity(&paths, minimum, &PlatformStateFilesystemCapacitySource)
+ .is_ok()
+ }
+
+ async fn probe_state(&self, check: MycDoctorCheckId) -> bool {
+ let Ok(state) =
+ open_myc_state_inspection_from_config(self.runtime, self.configuration).await
+ else {
+ return false;
+ };
+ let outcome = match check {
+ MycDoctorCheckId::WriterLock => true,
+ MycDoctorCheckId::SqliteSchema => state.repository().verify_binding().await.is_ok(),
+ MycDoctorCheckId::SqliteIntegrity => match integrity_time() {
+ Some(checked_at) => state
+ .inspect_integrity(checked_at)
+ .await
+ .is_ok_and(|report| {
+ report.sqlite() == IntegrityCheckOutcome::Verified
+ && report.foreign_keys() == IntegrityCheckOutcome::Verified
+ }),
+ None => false,
+ },
+ MycDoctorCheckId::OutboxInvariants => state
+ .repository()
+ .verify_delivery_invariants()
+ .await
+ .is_ok(),
+ _ => false,
+ };
+ let closed = state.close().await.is_ok();
+ outcome && closed
+ }
+
+ fn probe_free_space(&self) -> bool {
+ let Some(minimum) = self
+ .configuration
+ .normalized()
+ .pointer("/database/minimum_free_bytes")
+ .and_then(serde_json::Value::as_u64)
+ .and_then(|value| MinimumFreeBytes::new(value).ok())
+ else {
+ return false;
+ };
+ let Ok(paths) = crate::state_host::state_paths(self.runtime) else {
+ return false;
+ };
+ inspect_state_filesystem_capacity(&paths, minimum, &PlatformStateFilesystemCapacitySource)
+ .is_ok_and(|capacity| capacity.allows_authoritative_admission())
+ }
+
+ async fn probe_identity_binding(&self) -> bool {
+ let cancellation = MycTaskCancellation::uncancelled();
+ let Ok(executor) =
+ MycProviderExecutor::open(self.runtime, self.configuration, &cancellation).await
+ else {
+ return false;
+ };
+ self.configuration
+ .provider_contract()
+ .bindings()
+ .iter()
+ .all(|binding| executor.contains_role(binding.role()))
+ }
+
+ async fn probe_signer_provider(&self) -> bool {
+ let cancellation = MycTaskCancellation::uncancelled();
+ let Ok(executor) =
+ MycProviderExecutor::open(self.runtime, self.configuration, &cancellation).await
+ else {
+ return false;
+ };
+ let Some(observed_at) = wall_time_millis() else {
+ return false;
+ };
+ let mut seed = [0_u8; 32];
+ if SystemEntropy.fill_bytes(&mut seed).is_err() {
+ return false;
+ }
+ executor
+ .probe_all(observed_at, seed, &cancellation)
+ .await
+ .is_ok()
+ }
+
+ fn probe_admin_policy(&self) -> bool {
+ let path = self.runtime.artifacts().admin_socket();
+ path.is_absolute()
+ && path.to_str().is_some_and(|value| value.len() <= 4_096)
+ && admin_transport_limits(self.configuration).is_ok()
+ }
+
+ fn probe_operations_policy(&self) -> bool {
+ let Some(enabled) = self
+ .configuration
+ .normalized()
+ .pointer("/operations/enabled")
+ .and_then(serde_json::Value::as_bool)
+ else {
+ return false;
+ };
+ !enabled
+ || (self
+ .configuration
+ .normalized()
+ .pointer("/operations/listen")
+ .and_then(serde_json::Value::as_str)
+ .is_some()
+ && self
+ .configuration
+ .normalized()
+ .pointer("/operations/bind_policy")
+ .and_then(serde_json::Value::as_str)
+ .is_some())
+ }
+}
+
+impl MycDoctorProbe for MycSystemDoctorProbe<'_> {
+ fn probe(&self, definition: MycDoctorCheckDefinition) -> MycDoctorFuture<'_> {
+ Box::pin(async move {
+ if definition.id() == MycDoctorCheckId::ClockSkew {
+ MycDoctorObservation::Skipped
+ } else if self.run(definition).await {
+ MycDoctorObservation::Pass
+ } else {
+ MycDoctorObservation::Fail
+ }
+ })
+ }
+}
+
+fn wall_time_millis() -> Option<u64> {
+ SystemWallClock
+ .now_utc()
+ .ok()
+ .and_then(|time| time.get().checked_mul(1_000))
+ .filter(|value| i64::try_from(*value).is_ok())
+}
+
+fn absolute_deadline(duration_ms: u64) -> Option<u64> {
+ wall_time_millis()?.checked_add(duration_ms)
+}
+
+fn integrity_time() -> Option<IntegrityCheckedAtUnixMs> {
+ IntegrityCheckedAtUnixMs::new(wall_time_millis()?)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn deadline_math_is_checked_and_clock_skew_remains_unclaimed() {
+ assert!(absolute_deadline(15_000).is_some());
+ assert!(integrity_time().is_some());
+ }
+
+ #[test]
+ fn production_probe_source_retains_no_raw_error_projection() {
+ let source = include_str!("system_doctor.rs")
+ .split("#[cfg(test)]")
+ .next()
+ .expect("production source");
+ for forbidden in ["format!(\"{error", "to_string()", "source()"] {
+ assert!(!source.contains(forbidden), "found `{forbidden}`");
+ }
+ }
+}
diff --git a/src/transport_nostr_adapter.rs b/src/transport_nostr_adapter.rs
@@ -10,10 +10,11 @@ use std::{collections::BTreeMap, error::Error};
use radroots_event_codec::Codec;
use radroots_transport::{
- Target, TargetSet,
- outcome::DeliveryOutcomeKind,
+ EventSource, FetchRequest, Target, TargetSet,
+ outcome::{DeliveryOutcomeKind, FetchTargetState},
policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy},
sink::{DeliveryPayload, DeliveryRequest, DeliveryTargetReceipt},
+ source::{FetchBounds, FetchSelector},
};
use radroots_transport_nostr::{
Config, NostrTransport, PreparedDelivery, RelayAccess, RelayEndpoint, RelayProfile,
@@ -207,6 +208,132 @@ impl MycNostrDeliveryAdapter {
}
Ok(Self { targets })
}
+
+ pub(crate) async fn probe_required_relays(
+ configuration: &MycConfigDocumentV1,
+ deadline_unix_ms: u64,
+ ) -> Result<(), MycRelayAdapterError> {
+ let relays = configuration
+ .normalized()
+ .pointer("/relays")
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(|| adapter_error(MycRelayAdapterErrorKind::Configuration))?;
+ let connect_timeout =
+ configuration_integer(configuration, "/transport/connect_deadline_ms")?;
+ let request_timeout = configuration_integer(
+ configuration,
+ "/transport/publish_retry/attempt_deadline_ms",
+ )?;
+ let mut public = Vec::new();
+ let mut public_targets = Vec::new();
+ let mut local = Vec::new();
+ let mut local_targets = Vec::new();
+ for relay in relays.iter().filter(|relay| {
+ relay
+ .pointer("/required")
+ .and_then(serde_json::Value::as_bool)
+ == Some(true)
+ }) {
+ let url = relay
+ .pointer("/url")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| adapter_error(MycRelayAdapterErrorKind::Configuration))?;
+ let target = Target::nostr_relay(url)
+ .map_err(|_| adapter_error(MycRelayAdapterErrorKind::Target))?;
+ let (kind, policy) = if url.starts_with("wss://") {
+ (RelayProfileKind::Public, RelayUrlPolicy::Public)
+ } else if configuration.profile() == MycConfigProfile::RepoLocal
+ && url.starts_with("ws://")
+ {
+ (RelayProfileKind::Simulator, RelayUrlPolicy::Local)
+ } else {
+ return Err(adapter_error(MycRelayAdapterErrorKind::Configuration));
+ };
+ let endpoint = RelayEndpoint::new(url, policy, RelayAccess::ReadOnly)
+ .map_err(|_| adapter_error(MycRelayAdapterErrorKind::Configuration))?;
+ match kind {
+ RelayProfileKind::Public => {
+ public.push(endpoint);
+ public_targets.push(target);
+ }
+ RelayProfileKind::Simulator => {
+ local.push(endpoint);
+ local_targets.push(target);
+ }
+ RelayProfileKind::Device => {
+ return Err(adapter_error(MycRelayAdapterErrorKind::Configuration));
+ }
+ _ => return Err(adapter_error(MycRelayAdapterErrorKind::Configuration)),
+ }
+ }
+ if public_targets.is_empty() && local_targets.is_empty() {
+ return Err(adapter_error(MycRelayAdapterErrorKind::Configuration));
+ }
+ probe_group(
+ RelayProfileKind::Public,
+ public,
+ public_targets,
+ connect_timeout,
+ request_timeout,
+ deadline_unix_ms,
+ "myc-doctor-public",
+ )
+ .await?;
+ probe_group(
+ RelayProfileKind::Simulator,
+ local,
+ local_targets,
+ connect_timeout,
+ request_timeout,
+ deadline_unix_ms,
+ "myc-doctor-local",
+ )
+ .await
+ }
+}
+
+async fn probe_group(
+ kind: RelayProfileKind,
+ endpoints: Vec<RelayEndpoint>,
+ targets: Vec<Target>,
+ connect_timeout: u64,
+ request_timeout: u64,
+ deadline_unix_ms: u64,
+ request_id: &'static str,
+) -> Result<(), MycRelayAdapterError> {
+ if targets.is_empty() {
+ return Ok(());
+ }
+ let transport = build_transport(kind, endpoints, connect_timeout, request_timeout)?
+ .ok_or_else(|| adapter_error(MycRelayAdapterErrorKind::Configuration))?;
+ let target_set =
+ TargetSet::new(targets).map_err(|_| adapter_error(MycRelayAdapterErrorKind::Target))?;
+ let selector = FetchSelector::all()
+ .with_since_unix_seconds(u64::MAX)
+ .map_err(|_| adapter_error(MycRelayAdapterErrorKind::Configuration))?;
+ let request = FetchRequest::new(
+ request_id,
+ target_set,
+ FetchBounds::new(1, deadline_unix_ms)
+ .map_err(|_| adapter_error(MycRelayAdapterErrorKind::Configuration))?,
+ )
+ .map_err(|_| adapter_error(MycRelayAdapterErrorKind::Configuration))?
+ .with_selector(selector);
+ let page = transport
+ .fetch(request)
+ .await
+ .map_err(|_| adapter_error(MycRelayAdapterErrorKind::Execution))?;
+ if page.target_outcomes().is_empty()
+ || page.target_outcomes().iter().any(|outcome| {
+ !matches!(
+ outcome.state(),
+ FetchTargetState::Complete | FetchTargetState::Partial
+ )
+ })
+ {
+ return Err(adapter_error(MycRelayAdapterErrorKind::Execution));
+ }
+ Ok(())
}
impl MycRelayAdapter for MycNostrDeliveryAdapter {
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -29,6 +29,10 @@ const DIAGNOSTICS_V1: &str = include_str!("../src/diagnostics_v1.rs");
const DIAGNOSTICS_CONTRACT: &str =
include_str!("../contracts/services_hardening/diagnostics.v1.json");
const MAIN: &str = include_str!("../src/main.rs");
+const PROCESS_V1: &str = include_str!("../src/process_v1.rs");
+const PROCESS_V1_UNSUPPORTED: &str = include_str!("../src/process_v1_unsupported.rs");
+const CONFIG_LOADER: &str = include_str!("../src/config_loader.rs");
+const SYSTEM_DOCTOR: &str = include_str!("../src/system_doctor.rs");
const STATUS_V1: &str = include_str!("../src/status_v1.rs");
const RUNTIME_SUPERVISION: &str = include_str!("../src/runtime_supervision.rs");
const RUNTIME_SUPERVISION_CONTRACT: &str =
@@ -56,7 +60,9 @@ const DELIVERY_RECOVERY_EXPORT_CONTRACT: &str =
include_str!("../contracts/services_hardening/delivery_recovery_export.v1.json");
const SOURCES: &[&str] = &[
include_str!("../src/admin_v1.rs"),
+ include_str!("../src/cli_bootstrap.rs"),
include_str!("../src/cli_v1.rs"),
+ include_str!("../src/config_loader.rs"),
include_str!("../src/config_v1.rs"),
include_str!("../src/control_plane_wave_090_a.rs"),
include_str!("../src/delivery_worker.rs"),
@@ -68,6 +74,8 @@ const SOURCES: &[&str] = &[
include_str!("../src/nip46_verification.rs"),
include_str!("../src/nip46_work.rs"),
include_str!("../src/operations_v1.rs"),
+ include_str!("../src/process_v1.rs"),
+ include_str!("../src/process_v1_unsupported.rs"),
include_str!("../src/provider_contract.rs"),
include_str!("../src/provider_credential.rs"),
include_str!("../src/provider_envelope.rs"),
@@ -78,6 +86,7 @@ const SOURCES: &[&str] = &[
include_str!("../src/runtime_foundation.rs"),
include_str!("../src/runtime_supervision.rs"),
include_str!("../src/status_v1.rs"),
+ include_str!("../src/system_doctor.rs"),
include_str!("../src/transport_nostr_adapter.rs"),
include_str!("../src/state_catalog.rs"),
include_str!("../src/state_admin.rs"),
@@ -105,7 +114,9 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
.collect::<BTreeSet<_>>(),
BTreeSet::from([
"admin_v1",
+ "cli_bootstrap",
"cli_v1",
+ "config_loader",
"config_v1",
"control_plane_wave_090_a",
"delivery_worker",
@@ -119,6 +130,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"nip46_wave_080_a",
"nip46_wave_080_b",
"operations_v1",
+ "process_v1",
"provider_contract",
"provider_credential",
"provider_envelope",
@@ -129,6 +141,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"runtime_foundation",
"runtime_supervision",
"status_v1",
+ "system_doctor",
"transport_nostr_adapter",
"state_catalog",
"state_admin",
@@ -167,7 +180,12 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"The journal stores no request body, path,\ncorrelation ID, credential, bundle path, or secret",
"The Step 159 provider and delivery boundary is sealed inside the crate",
"persists Submitted immediately before execution",
- "Runtime task-graph wiring and startup handshakes remain the next\nordered Step 159 unit",
+ "The selected absolute config path is opened no-follow through its retained\nparent descriptor",
+ "One binary-owned Tokio runtime is created from the validated fixed thread\nlimits",
+ "Restore derives expected backup identity\nfrom the trusted manifest digest",
+ "The production adapter composes secure path and disk inspection",
+ "It never publishes a relay event",
+ "runtime task-graph wiring and startup\nhandshakes remain the next ordered Step 159 unit",
] {
assert!(README.contains(required), "README is missing `{required}`");
}
@@ -181,6 +199,18 @@ 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 enum myc::MycCliOutputModeV1",
+ "pub struct myc::MycConfigApplyArgsV1",
+ "pub struct myc::MycStateBackupArgsV1",
+ "pub struct myc::MycStateRestoreArgsV1",
+ "pub struct myc::MycIdentityCommandArgsV1",
+ "pub struct myc::MycRuntimeThreadLimitsV1",
+ "pub struct myc::MycConfigLoadError",
+ "pub enum myc::MycConfigLoadErrorKind",
+ "pub fn myc::execute_myc_cli_v1",
+ "pub fn myc::initialize_myc_config_document",
+ "pub fn myc::load_myc_config_candidate",
+ "pub fn myc::load_myc_config_document",
"pub struct myc::MycDoctorReport",
"pub struct myc::MycLogRecord",
"pub enum myc::MycLogEvent",
@@ -302,7 +332,9 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
for module in [
"admin_v1",
+ "cli_bootstrap",
"cli_v1",
+ "config_loader",
"config_v1",
"control_plane_wave_090_a",
"delivery_worker",
@@ -316,6 +348,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"nip46_wave_080_a",
"nip46_wave_080_b",
"operations_v1",
+ "process_v1",
"provider_contract",
"provider_credential",
"provider_envelope",
@@ -326,6 +359,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"runtime_foundation",
"runtime_supervision",
"status_v1",
+ "system_doctor",
"transport_nostr_adapter",
"state_catalog",
"state_admin",
@@ -935,7 +969,7 @@ fn step150_admin_adapter_is_closed_typed_and_transport_bounded() {
"Raw shared-host routers and JSON values never cross the public",
"operation_id_conflict",
"Unit 13\nseals that handler boundary inside the production `MycAdminServer`",
- "Unit 15\nalone owns task spawning, provider/relay wiring, readiness, reconnect, and\nphase-aware shutdown",
+ "Unit 15 alone owns task\nspawning, provider/relay wiring, readiness, reconnect, and phase-aware\nshutdown",
] {
assert!(README.contains(required), "README is missing `{required}`");
}
@@ -986,6 +1020,122 @@ fn step159_unit13_control_surfaces_are_sealed_and_machine_bound() {
}
#[test]
+fn step159_unit14_process_bootstrap_is_secure_bounded_and_daemon_deferred() {
+ let process = PROCESS_V1
+ .split("#[cfg(test)]")
+ .next()
+ .expect("production process source");
+ let system_doctor = SYSTEM_DOCTOR
+ .split("#[cfg(test)]")
+ .next()
+ .expect("production doctor source");
+ assert_eq!(MAIN.matches("parse_myc_cli_v1_from").count(), 1);
+ assert_eq!(MAIN.matches("execute_myc_cli_v1").count(), 1);
+ for required in [
+ "plan_myc_cli_v1(&invocation)",
+ "Builder::new_multi_thread()",
+ "worker_threads(limits.worker_threads())",
+ "max_blocking_threads(limits.blocking_threads())",
+ "ServiceBackupManifest::from_canonical_bytes",
+ "parsed.digest() != arguments.manifest_sha256()",
+ "read_secure_bounded_file(path, maximum)",
+ "emit_exact_bytes(manifest.canonical_bytes())",
+ ] {
+ assert!(
+ process.contains(required),
+ "Unit 14 process boundary is missing `{required}`"
+ );
+ }
+ assert_eq!(process.matches("Builder::new_multi_thread()").count(), 1);
+ for forbidden in [
+ "available_parallelism",
+ "std::process::exit",
+ "path(\"MYC_",
+ "tokio::spawn",
+ "tokio::task::spawn",
+ ] {
+ assert!(
+ !process.contains(forbidden),
+ "Unit 14 process boundary gained `{forbidden}`"
+ );
+ }
+
+ for required in [
+ "OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK",
+ "OFlags::WRONLY",
+ "OFlags::CREATE",
+ "OFlags::EXCL",
+ "Mode::RUSR | Mode::WUSR",
+ "normalize_link_count(status.st_nlink) != 1",
+ "status.st_uid != geteuid().as_raw()",
+ "mode & 0o022 != 0",
+ "file.sync_all()",
+ "parent.sync_all()",
+ "open_parent(&selected.parent)",
+ ] {
+ assert!(
+ CONFIG_LOADER.contains(required),
+ "Unit 14 config loader is missing `{required}`"
+ );
+ }
+ for forbidden in ["canonicalize(", "create_dir_all", "from_current_process"] {
+ assert!(
+ !CONFIG_LOADER.contains(forbidden),
+ "Unit 14 config loader gained `{forbidden}`"
+ );
+ }
+
+ for required in [
+ "MycDoctorCheckId::PathsPermissions",
+ "MycDoctorCheckId::WriterLock",
+ "MycDoctorCheckId::SqliteSchema",
+ "MycDoctorCheckId::SqliteFreeSpace",
+ "MycDoctorCheckId::SqliteIntegrity",
+ "MycDoctorCheckId::OutboxInvariants",
+ "MycDoctorCheckId::IdentityBinding",
+ "MycDoctorCheckId::SignerProvider",
+ "MycDoctorCheckId::AdminBindPolicy",
+ "MycDoctorCheckId::OperationsBindPolicy",
+ "MycDoctorCheckId::NetworkPolicy",
+ "MycDoctorCheckId::RequiredRelays",
+ "MycDoctorCheckId::ClockSkew",
+ "MycDoctorObservation::Skipped",
+ "probe_required_relays",
+ ] {
+ assert!(
+ system_doctor.contains(required),
+ "Unit 14 doctor adapter is missing `{required}`"
+ );
+ }
+ for forbidden in ["publish(", "format!(\"{error", "to_string()", "source()"] {
+ assert!(
+ !system_doctor.contains(forbidden),
+ "Unit 14 doctor adapter gained `{forbidden}`"
+ );
+ }
+
+ for required in [
+ "MycProcessResult::ServiceOrDependencyUnavailable",
+ "MycProcessResult::InputOrConfiguration",
+ ] {
+ assert!(PROCESS_V1_UNSUPPORTED.contains(required));
+ }
+ for forbidden in [
+ "std::fs",
+ "sqlx::",
+ "AdminClient",
+ "tokio::",
+ "MycStateHost",
+ "MycProviderExecutor",
+ ] {
+ assert!(
+ !PROCESS_V1_UNSUPPORTED.contains(forbidden),
+ "unsupported process executor gained `{forbidden}`"
+ );
+ }
+}
+
+#[test]
fn step144_authorization_is_configuration_bound_and_reuses_durable_state() {
for forbidden in [
"sqlx::",
@@ -1072,11 +1222,12 @@ 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, 35);
+ assert_eq!(public_error_count, 36);
assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError"));
assert!(PUBLIC_API.contains("pub struct myc::MycConfigApplyError"));
assert!(PUBLIC_API.contains("pub struct myc::MycAdminOperationError"));
assert!(PUBLIC_API.contains("pub struct myc::MycAdminServerError"));
+ assert!(PUBLIC_API.contains("pub struct myc::MycConfigLoadError"));
assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {"));
assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {"));
}
@@ -1211,7 +1362,10 @@ fn step159_provider_delivery_is_sealed_exact_and_durability_ordered() {
);
for required in [
"spawn_blocking",
- "let _ = worker.await",
+ "OwnedBlockingTask",
+ "worker.join(MycProviderExecutionErrorKind::Open).await",
+ "handle.abort()",
+ "handle.is_finished()",
"verify_encrypted_provider_response",
"MycLocalSignerClient",
] {
diff --git a/tests/services_hardening_cli.rs b/tests/services_hardening_cli.rs
@@ -12,6 +12,7 @@ use myc::{
const CLI_SOURCE: &str = include_str!("../src/cli_v1.rs");
const MAIN_SOURCE: &str = include_str!("../src/main.rs");
+const PROCESS_SOURCE: &str = include_str!("../src/process_v1.rs");
const OPERATOR_CONTRACT: &str =
include_str!("../contracts/services_hardening/operator_contract.v1.json");
@@ -30,73 +31,101 @@ fn base(command: &[&str]) -> Vec<String> {
#[test]
fn root_api_freezes_the_exact_command_inventory() {
let vectors = [
- (vec!["run"], MycCommandV1::Run),
+ (vec!["run"], "run"),
+ (vec!["config", "init"], "config_init"),
+ (vec!["config", "validate"], "config_validate"),
+ (vec!["config", "show"], "config_show"),
+ (vec!["config", "schema"], "config_schema"),
(
- vec!["config", "init"],
- MycCommandV1::Config(MycConfigCommandV1::Init),
- ),
- (
- vec!["config", "validate"],
- MycCommandV1::Config(MycConfigCommandV1::Validate),
- ),
- (
- vec!["config", "show"],
- MycCommandV1::Config(MycConfigCommandV1::Show),
- ),
- (
- vec!["config", "schema"],
- MycCommandV1::Config(MycConfigCommandV1::Schema),
- ),
- (
- vec!["state", "init"],
- MycCommandV1::State(MycStateCommandV1::Init),
- ),
- (
- vec!["state", "status"],
- MycCommandV1::State(MycStateCommandV1::Status),
+ vec!["config", "apply", "--candidate-config", "/candidate.toml"],
+ "config_apply",
),
+ (vec!["state", "init"], "state_init"),
+ (vec!["state", "status"], "state_status"),
+ (backup_command(), "state_backup"),
+ (restore_command(), "state_restore"),
+ (vec!["state", "verify"], "state_verify"),
+ (vec!["state", "migrate"], "state_migrate"),
(
- vec!["state", "backup"],
- MycCommandV1::State(MycStateCommandV1::Backup),
+ vec!["identity", "init", "--role", "transport"],
+ "identity_init",
),
(
- vec!["state", "restore"],
- MycCommandV1::State(MycStateCommandV1::Restore),
- ),
- (
- vec!["state", "verify"],
- MycCommandV1::State(MycStateCommandV1::Verify),
- ),
- (
- vec!["state", "migrate"],
- MycCommandV1::State(MycStateCommandV1::Migrate),
+ vec!["identity", "status", "--role", "user"],
+ "identity_status",
),
(
- vec!["identity", "init"],
- MycCommandV1::Identity(MycIdentityCommandV1::Init),
+ vec!["identity", "export-public", "--role", "discovery"],
+ "identity_export_public",
),
- (
- vec!["identity", "status"],
- MycCommandV1::Identity(MycIdentityCommandV1::Status),
- ),
- (
- vec!["identity", "export-public"],
- MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic),
- ),
- (vec!["status"], MycCommandV1::Status),
- (vec!["doctor"], MycCommandV1::Doctor),
+ (vec!["status"], "status"),
+ (vec!["doctor"], "doctor"),
];
for (arguments, expected) in vectors {
assert_eq!(
- parse_myc_cli_v1_from(base(&arguments))
- .expect("governed command")
- .command(),
+ command_name(
+ parse_myc_cli_v1_from(base(&arguments))
+ .expect("governed command")
+ .command()
+ ),
expected
);
}
}
+fn backup_command() -> Vec<&'static str> {
+ vec![
+ "state",
+ "backup",
+ "--operation-id",
+ "backup-01",
+ "--target",
+ "/backup/new",
+ "--expected-generation",
+ "7",
+ "--confirm",
+ ]
+}
+
+fn restore_command() -> Vec<&'static str> {
+ vec![
+ "state",
+ "restore",
+ "--manifest",
+ "/backup/manifest.json",
+ "--manifest-sha256",
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ "--bundle",
+ "/backup/bundle",
+ "--maximum-state-bytes",
+ "1048576",
+ "--confirm",
+ ]
+}
+
+fn command_name(command: &MycCommandV1) -> &'static str {
+ match command {
+ MycCommandV1::Run => "run",
+ MycCommandV1::Config(MycConfigCommandV1::Init) => "config_init",
+ MycCommandV1::Config(MycConfigCommandV1::Validate) => "config_validate",
+ MycCommandV1::Config(MycConfigCommandV1::Show) => "config_show",
+ MycCommandV1::Config(MycConfigCommandV1::Schema) => "config_schema",
+ MycCommandV1::Config(MycConfigCommandV1::Apply(_)) => "config_apply",
+ MycCommandV1::State(MycStateCommandV1::Init) => "state_init",
+ MycCommandV1::State(MycStateCommandV1::Status) => "state_status",
+ MycCommandV1::State(MycStateCommandV1::Backup(_)) => "state_backup",
+ MycCommandV1::State(MycStateCommandV1::Restore(_)) => "state_restore",
+ MycCommandV1::State(MycStateCommandV1::Verify) => "state_verify",
+ MycCommandV1::State(MycStateCommandV1::Migrate) => "state_migrate",
+ MycCommandV1::Identity(MycIdentityCommandV1::Init(_)) => "identity_init",
+ MycCommandV1::Identity(MycIdentityCommandV1::Status(_)) => "identity_status",
+ MycCommandV1::Identity(MycIdentityCommandV1::ExportPublic(_)) => "identity_export_public",
+ MycCommandV1::Status => "status",
+ MycCommandV1::Doctor => "doctor",
+ }
+}
+
#[test]
fn root_api_exposes_validated_cross_bound_bootstrap_values() {
let invocation = parse_myc_cli_v1_from([
@@ -163,6 +192,14 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
false,
),
(
+ "config apply",
+ vec!["config", "apply", "--candidate-config", "/candidate.toml"],
+ "offline",
+ Some("config"),
+ None,
+ false,
+ ),
+ (
"state init",
vec!["state", "init"],
"offline",
@@ -180,7 +217,7 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"state backup",
- vec!["state", "backup"],
+ backup_command(),
"live_unix_admin",
Some("state_read_only"),
Some("/v1/state/backup"),
@@ -188,7 +225,7 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"state restore",
- vec!["state", "restore"],
+ restore_command(),
"offline",
Some("state_exclusive"),
None,
@@ -212,7 +249,7 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"identity init",
- vec!["identity", "init"],
+ vec!["identity", "init", "--role", "transport"],
"offline",
Some("identity_exclusive"),
None,
@@ -220,7 +257,7 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"identity status",
- vec!["identity", "status"],
+ vec!["identity", "status", "--role", "user"],
"live_unix_admin",
Some("identity_read_only"),
Some("/v1/identity/status"),
@@ -228,7 +265,7 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"identity export-public",
- vec!["identity", "export-public"],
+ vec!["identity", "export-public", "--role", "discovery"],
"live_unix_admin",
Some("identity_read_only"),
Some("/v1/identity/public"),
@@ -260,6 +297,7 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
assert_eq!(
dispatch.keys().map(String::as_str).collect::<BTreeSet<_>>(),
BTreeSet::from([
+ "bootstrap",
"commands",
"live_direct_sqlite_access",
"live_mutation_offline_fallback",
@@ -296,6 +334,74 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
.and_then(serde_json::Value::as_bool),
Some(false)
);
+ assert_eq!(
+ dispatch.get("bootstrap"),
+ Some(&serde_json::json!({
+ "output_modes": ["human", "json"],
+ "default_output_mode": "human",
+ "stdout": "results_only",
+ "stderr": "diagnostics_only",
+ "config_init": {
+ "source": "bounded_nonsecret_toml_stdin",
+ "persistence": "create_new_selected_path_mode_0600_file_and_parent_sync",
+ },
+ "config_loader": {
+ "path": "selected_absolute_explicit_or_canonical_default",
+ "maximum_utf8_bytes": 1048576,
+ "no_follow": true,
+ "regular_file": true,
+ "single_link": true,
+ "effective_user_owner": true,
+ "group_or_other_write": false,
+ "revalidate_after_read": ["parent_device_inode", "file_device_inode", "file_length"],
+ },
+ "config_apply": {
+ "candidate_argument": "--candidate-config_absolute_path",
+ "current_source": "selected_config_path",
+ "mutates_config_files": false,
+ },
+ "identity": {
+ "role_argument": "--role_transport_user_discovery",
+ "init_provider": "configured_encrypted_file_only",
+ "init_secret_source": "stdin_fixed_binary_v1_117_bytes",
+ },
+ "state_init": {
+ "source_generation": "system_entropy_nonzero_32_bytes",
+ "created_at": "system_wall_clock",
+ "existing_state_open": "sealed_intent_discovers_actual_metadata",
+ },
+ "runtime": {
+ "owner": "myc_binary",
+ "count_per_process": 1,
+ "worker_threads_default": 4,
+ "worker_threads_range": [2, 32],
+ "blocking_threads_default": 8,
+ "blocking_threads_range": [1, 32],
+ "cpu_derived_defaults": false,
+ },
+ "backup": {
+ "operation_id_argument": "--operation-id",
+ "target_argument": "--target-new-absolute-directory",
+ "expected_generation_argument": "--expected-generation",
+ "confirmation_argument": "--confirm",
+ "offline_stdout": "exact_canonical_manifest_bytes_no_trailing_newline",
+ },
+ "restore": {
+ "manifest_argument": "--manifest-absolute-file",
+ "manifest_digest_argument": "--manifest-sha256",
+ "bundle_argument": "--bundle-absolute-directory",
+ "maximum_state_bytes_argument": "--maximum-state-bytes",
+ "confirmation_argument": "--confirm",
+ "expected_identity_source": "trusted_digest_bound_manifest_before_live_database_open",
+ },
+ "run": {
+ "config_source": "secure_selected_path_loader",
+ "runtime_owner": "myc_binary",
+ "graph_owner": "myc-runtime-graph-shutdown",
+ },
+ "unsupported_command_success": false,
+ }))
+ );
let commands = dispatch
.get("commands")
.and_then(serde_json::Value::as_array)
@@ -427,6 +533,8 @@ fn execution_plan_debug_retains_no_bootstrap_or_path_values() {
"/secret/config.toml",
"identity",
"export-public",
+ "--role",
+ "discovery",
])
.expect("valid invocation");
let rendered = format!("{invocation:?} {:?}", plan_myc_cli_v1(&invocation));
@@ -477,7 +585,13 @@ fn parser_is_single_pass_pure_and_privately_implemented() {
}
assert_eq!(MAIN_SOURCE.matches("parse_myc_cli_v1_from").count(), 1);
- assert_eq!(MAIN_SOURCE.matches("plan_myc_cli_v1").count(), 1);
+ assert_eq!(MAIN_SOURCE.matches("execute_myc_cli_v1").count(), 1);
+ assert_eq!(
+ PROCESS_SOURCE
+ .matches("plan_myc_cli_v1(&invocation)")
+ .count(),
+ 1
+ );
for forbidden in ["sqlx::", "open_myc_state_", "MycStateHost"] {
assert!(!MAIN_SOURCE.contains(forbidden), "found `{forbidden}`");
}
diff --git a/tests/services_hardening_config_contract.rs b/tests/services_hardening_config_contract.rs
@@ -360,7 +360,7 @@ fn schema_identity_structure_and_machine_policy_are_exact() {
let mut found_defaults = BTreeMap::new();
defaults(&schema, "", &mut found_defaults);
- assert_eq!(found_defaults.len(), 39);
+ assert_eq!(found_defaults.len(), 41);
for source in found_defaults.keys().map(|pointer| {
schema.pointer(pointer).unwrap()["x-radroots-default-source"]
.as_str()
@@ -380,6 +380,26 @@ fn schema_identity_structure_and_machine_policy_are_exact() {
fn lib_derived_limits_and_defaults_are_literal_frozen() {
let schema = schema();
assert_eq!(
+ schema["$defs"]["runtime_limits"]["properties"]["worker_threads"],
+ json!({
+ "type": "integer",
+ "minimum": 2,
+ "maximum": 32,
+ "default": 4,
+ "x-radroots-default-source": "engineering_safety",
+ })
+ );
+ assert_eq!(
+ schema["$defs"]["runtime_limits"]["properties"]["blocking_threads"],
+ json!({
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 32,
+ "default": 8,
+ "x-radroots-default-source": "engineering_safety",
+ })
+ );
+ assert_eq!(
schema["$defs"]["operations_limits"]["properties"]["header_bytes"]["minimum"],
8_192
);
diff --git a/tests/services_hardening_diagnostics.rs b/tests/services_hardening_diagnostics.rs
@@ -128,8 +128,12 @@ fn binary_writes_only_fixed_json_diagnostics_to_stderr() {
);
assert!(!invalid_stderr.contains(canary));
+ let repo_local = tempfile::tempdir().expect("repo-local root");
let unavailable = Command::new(env!("CARGO_BIN_EXE_myc"))
- .args(["--profile", "service-host", "--instance", "primary", "run"])
+ .args(["--profile", "repo-local", "--instance", "primary"])
+ .arg("--repo-local-root")
+ .arg(repo_local.path())
+ .arg("run")
.output()
.expect("admitted invocation");
assert_eq!(unavailable.status.code(), Some(3));
diff --git a/tests/services_hardening_process.rs b/tests/services_hardening_process.rs
@@ -0,0 +1,242 @@
+#![forbid(unsafe_code)]
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::io::Write as _;
+use std::os::unix::fs::PermissionsExt as _;
+use std::path::PathBuf;
+use std::process::{Command, Output, Stdio};
+
+use sha2::{Digest, Sha256};
+
+const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+
+struct ProcessFixture {
+ root: tempfile::TempDir,
+ config: PathBuf,
+}
+
+impl ProcessFixture {
+ fn new() -> Self {
+ let root = tempfile::tempdir().expect("repo-local root");
+ std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure repo-local root");
+ let config = root.path().join("myc.toml");
+ Self { root, config }
+ }
+
+ fn command(&self, command: &[&str]) -> Command {
+ let mut process = Command::new(env!("CARGO_BIN_EXE_myc"));
+ process
+ .args(["--profile", "repo-local", "--instance", "primary"])
+ .arg("--repo-local-root")
+ .arg(self.root.path())
+ .arg("--config")
+ .arg(&self.config)
+ .args(command);
+ process
+ }
+
+ fn run(&self, command: &[&str]) -> Output {
+ self.command(command).output().expect("Myc process")
+ }
+
+ fn run_with_stdin(&self, command: &[&str], bytes: &[u8]) -> Output {
+ let mut child = self
+ .command(command)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("Myc process");
+ child
+ .stdin
+ .take()
+ .expect("stdin")
+ .write_all(bytes)
+ .expect("write stdin");
+ child.wait_with_output().expect("process result")
+ }
+
+ fn state_directory(&self) -> PathBuf {
+ self.root.path().join("data/services/myc/primary")
+ }
+}
+
+fn repo_local_config() -> String {
+ CONFIG_EXAMPLE
+ .replace("wss://relay-primary.example.test/", "ws://127.0.0.1:9/")
+ .replace("wss://relay-secondary.example.test/", "ws://127.0.0.1:10/")
+ .replace("connect_deadline_ms = 10000", "connect_deadline_ms = 100")
+}
+
+fn stderr_code(output: &Output) -> String {
+ let value: serde_json::Value = serde_json::from_slice(&output.stderr).expect("diagnostic JSON");
+ value["code"].as_str().expect("diagnostic code").to_owned()
+}
+
+fn assert_success(output: &Output) {
+ assert_eq!(output.status.code(), Some(0), "stderr: {:?}", output.stderr);
+ assert_eq!(stderr_code(output), "success");
+}
+
+#[test]
+fn binary_executes_config_state_backup_restore_and_doctor_boundaries() {
+ let fixture = ProcessFixture::new();
+ let config = repo_local_config();
+
+ let initialized = fixture.run_with_stdin(&["config", "init"], config.as_bytes());
+ assert_success(&initialized);
+ assert_eq!(initialized.stdout, b"config_initialized\n");
+ assert_eq!(
+ std::fs::metadata(&fixture.config)
+ .expect("config metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o600
+ );
+ std::fs::create_dir_all(fixture.state_directory()).expect("provisioned state directory");
+ std::fs::set_permissions(
+ fixture.state_directory(),
+ std::fs::Permissions::from_mode(0o700),
+ )
+ .expect("secure state directory");
+
+ for (command, result) in [
+ (&["config", "validate"][..], b"config_valid\n".as_slice()),
+ (&["state", "init"][..], b"state_initialized\n".as_slice()),
+ (&["state", "verify"][..], b"state_verified\n".as_slice()),
+ (&["state", "migrate"][..], b"state_migrated\n".as_slice()),
+ ] {
+ let output = fixture.run(command);
+ assert_eq!(
+ output.status.code(),
+ Some(0),
+ "command {command:?}, stderr: {:?}",
+ output.stderr
+ );
+ assert_success(&output);
+ assert_eq!(output.stdout, result);
+ }
+
+ let shown = fixture.run(&["--output", "json", "config", "show"]);
+ assert_success(&shown);
+ let shown_value: serde_json::Value =
+ serde_json::from_slice(&shown.stdout).expect("effective configuration JSON");
+ assert_eq!(shown_value["schema"], "radroots.myc.effective-config");
+
+ let schema = fixture.run(&["config", "schema"]);
+ assert_success(&schema);
+ let schema_value: serde_json::Value =
+ serde_json::from_slice(&schema.stdout).expect("configuration schema JSON");
+ assert_eq!(
+ schema_value["$id"],
+ "https://github.com/radrootslabs/myc/contracts/services_hardening/config.v1.schema.json"
+ );
+
+ let status = fixture.run(&["state", "status"]);
+ assert_success(&status);
+ let status_value: serde_json::Value =
+ serde_json::from_slice(&status.stdout).expect("state status JSON");
+ assert_eq!(status_value["generation"], 1);
+ assert_eq!(status_value["schema_version"], 11);
+ assert_eq!(status_value["integrity"], "verified");
+
+ let bundle = fixture.root.path().join("backup");
+ let backup = fixture
+ .command(&["state", "backup", "--operation-id", "process-backup-01"])
+ .arg("--target")
+ .arg(&bundle)
+ .args(["--expected-generation", "1", "--confirm"])
+ .output()
+ .expect("backup process");
+ assert_success(&backup);
+ assert!(!backup.stdout.ends_with(b"\n"));
+ let manifest_bytes = backup.stdout.as_slice();
+ let manifest: serde_json::Value =
+ serde_json::from_slice(manifest_bytes).expect("backup manifest");
+ assert_eq!(manifest["service"], "myc");
+ assert_eq!(manifest["instance"], "primary");
+ let digest = hex::encode(Sha256::digest(manifest_bytes));
+ let manifest_path = fixture.root.path().join("manifest.json");
+ std::fs::write(&manifest_path, manifest_bytes).expect("manifest file");
+ std::fs::set_permissions(&manifest_path, std::fs::Permissions::from_mode(0o600))
+ .expect("secure manifest");
+
+ let live_database = fixture.state_directory().join("state.sqlite");
+ let mut corrupted = std::fs::OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .open(&live_database)
+ .expect("open live database for corruption fixture");
+ corrupted
+ .write_all(b"corrupt-live-database")
+ .and_then(|()| corrupted.sync_all())
+ .expect("persist corrupt live database fixture");
+ drop(corrupted);
+
+ let restored = fixture
+ .command(&["state", "restore"])
+ .arg("--manifest")
+ .arg(&manifest_path)
+ .arg("--manifest-sha256")
+ .arg(&digest)
+ .arg("--bundle")
+ .arg(&bundle)
+ .args(["--maximum-state-bytes", "16777216", "--confirm"])
+ .output()
+ .expect("restore process");
+ assert_success(&restored);
+ assert_eq!(restored.stdout, b"state_restore_finalized\n");
+
+ let verified_after_restore = fixture.run(&["state", "verify"]);
+ assert_success(&verified_after_restore);
+ for artifact in [
+ "state.restore-marker.v1",
+ "state.restore-marker.v1.next",
+ "state.restore-staged.sqlite",
+ "state.restore-backup.sqlite",
+ ] {
+ assert!(!fixture.state_directory().join(artifact).exists());
+ }
+
+ let doctor = fixture.run(&["doctor"]);
+ assert_eq!(doctor.status.code(), Some(6));
+ assert_eq!(stderr_code(&doctor), "doctor_required_check_failed");
+ let doctor_value: serde_json::Value =
+ serde_json::from_slice(&doctor.stdout).expect("doctor report JSON");
+ assert_eq!(doctor_value["checks"].as_array().expect("checks").len(), 13);
+ assert_eq!(doctor_value["checks"][12]["status"], "skipped");
+
+ let run = fixture.run(&["run"]);
+ assert_eq!(run.status.code(), Some(3));
+ assert!(run.stdout.is_empty());
+ assert_eq!(stderr_code(&run), "service_or_dependency_unavailable");
+}
+
+#[test]
+fn binary_rejects_insecure_or_oversized_selected_documents_without_disclosure() {
+ let fixture = ProcessFixture::new();
+ std::fs::write(&fixture.config, repo_local_config()).expect("config");
+ std::fs::set_permissions(&fixture.config, std::fs::Permissions::from_mode(0o620))
+ .expect("insecure config mode");
+
+ let insecure = fixture.run(&["config", "validate"]);
+ assert_eq!(insecure.status.code(), Some(2));
+ assert!(insecure.stdout.is_empty());
+ assert_eq!(stderr_code(&insecure), "input_or_configuration");
+ let rendered = String::from_utf8(insecure.stderr).expect("diagnostic text");
+ assert!(!rendered.contains(fixture.config.to_str().expect("UTF-8 path")));
+
+ std::fs::set_permissions(&fixture.config, std::fs::Permissions::from_mode(0o600))
+ .expect("secure config mode");
+ std::fs::write(
+ &fixture.config,
+ vec![b'x'; myc::MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1],
+ )
+ .expect("oversized config");
+ let oversized = fixture.run(&["config", "validate"]);
+ assert_eq!(oversized.status.code(), Some(2));
+ assert!(oversized.stdout.is_empty());
+ assert_eq!(stderr_code(&oversized), "input_or_configuration");
+}
diff --git a/tests/services_hardening_state_metadata.rs b/tests/services_hardening_state_metadata.rs
@@ -89,7 +89,7 @@ fn exact_database_configuration_identity_and_policy_bindings_are_frozen() {
assert_eq!(versions.status(), MYC_SIGNER_STATUS_CONTRACT_VERSION);
assert_eq!(
hex::encode(metadata.configuration_digest().as_bytes()),
- "5fd8ecb8d526ed8cc2d5af8963838ea9a3707a74d0da6ed7c97851a4d6760a44"
+ "56942af2ea11124114cae734dacdac75efd22c5476af6fe970e1d586d439b630"
);
}
@@ -103,7 +103,11 @@ fn digest_uses_fully_defaulted_values_and_changes_with_normalized_policy() {
.replace("level = \"info\"\n", "")
.replace("format = \"json\"\n", "")
.replace("busy_timeout_ms = 5000\n", "")
- .replace("max_connections = 8\n", "");
+ .replace("max_connections = 8\n", "")
+ .replace(
+ "[resource_limits.runtime]\nworker_threads = 4\nblocking_threads = 8\n\n",
+ "",
+ );
let implicit = state_metadata(&runtime, &implicit_source).expect("implicit defaults");
assert_eq!(
explicit.configuration_digest(),