myc

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

commit 5704286ef326288a0ef8f4aaca53f8dcc3682c56
parent 9e79449c2a4d3559c38c90e172b9ab8e58c8e91b
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 13:14:41 +0000

runtime-paths: adopt sealed Myc instance context

- replace the local resolver and environment model with the source-locked runtime_paths authority
- bind typed service and instance identity to exact common artifact paths
- add cross-profile, redaction, source-lock, and removal qualification

Diffstat:
MCargo.lock | 10++++++++++
MCargo.toml | 1+
MREADME | 10++++++++--
Mradroots.lib.source-lock.v1.toml | 2+-
Msrc/app/backend.rs | 5+----
Msrc/app/mod.rs | 12++++--------
Msrc/app/runtime.rs | 67+++++++++++++++++++++++++++++--------------------------------------
Msrc/cli_v1.rs | 38+++++++++++---------------------------
Msrc/config.rs | 332++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/control.rs | 3+--
Msrc/discovery.rs | 5+----
Msrc/lib.rs | 19++++++++++++++-----
Msrc/operability/mod.rs | 11++++-------
Msrc/paths.rs | 464+++++++++++++------------------------------------------------------------------
Msrc/persistence.rs | 39++++++++++++++++++++-------------------
Asrc/runtime_context.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/transport/nip46.rs | 3+--
Mtests/build_policy.rs | 7+++++++
Mtests/nip46_e2e.rs | 24++++++++++++------------
Mtests/operability_e2e.rs | 13+++++++------
Mtests/operability_server.rs | 13+++++++------
Mtests/services_hardening_cli.rs | 6+++---
Atests/services_hardening_runtime_context.rs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/support/mod.rs | 25+++++++++++++++++++++++++
24 files changed, 922 insertions(+), 678 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1531,6 +1531,7 @@ dependencies = [ "radroots_identity", "radroots_nostr", "radroots_nostr_connect", + "radroots_runtime_paths", "radroots_secrets", "radroots_signing", "rand 0.9.2", @@ -2050,6 +2051,15 @@ dependencies = [ ] [[package]] +name = "radroots_runtime_paths" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] name = "radroots_secrets" version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" diff --git a/Cargo.toml b/Cargo.toml @@ -43,6 +43,7 @@ radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "b44119 radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["serde"] } radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["events"] } radroots_nostr_connect = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } +radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std", "keyring"] } radroots_signing = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] } serde = { version = "1.0", features = ["derive"] } diff --git a/README b/README @@ -34,8 +34,14 @@ text, paths, credentials, relay URLs, or identity values. The prototype environment loader, `.env` example, environment selectors, implicit command/profile selection, compatibility aliases, and arbitrary leaf -flags have been removed. The remaining internal runtime model is not a process -configuration surface and is replaced only by its later owning checkpoints. +flags have been removed. Bootstrap now resolves a sealed runtime context from +the validated CLI profile and typed instance ID through the source-locked +`radroots_runtime_paths` authority. Config, state, cache, logs, run, secrets, +`config.toml`, `state.sqlite`, `state.lock`, and `admin.sock` are derived under +the exact `services/myc/<instance>` namespace. The service contains no local +host-environment resolver, worker namespace, ambient selector, or implicit +path default. An explicit absolute `--config` may select the document to read +without changing the canonical common artifact inventory. ## NIP-46 runtime contract diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml @@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e version = "0.1.0-alpha" source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379" lockfile = "Cargo.lock" -lockfile_sha256 = "06df285eb4b6ae0e88a70e4bf55710744f8bbf320fa2d1873c241138208823b5" +lockfile_sha256 = "25d0049df23e822284b368707f08d08ddcfac85c30703d7154baf80b9baadf3e" diff --git a/src/app/backend.rs b/src/app/backend.rs @@ -351,8 +351,6 @@ mod tests { use nostr::Keys; use crate::app::MycRuntime; - use crate::config::MycConfig; - fn write_identity(path: &std::path::Path, secret_key: &str) { let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); @@ -360,8 +358,7 @@ mod tests { fn test_runtime() -> MycRuntime { let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(&temp).join("state"); + let mut config = crate::config::test_config(PathBuf::from(&temp).as_path()); config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); write_identity( diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -41,11 +41,9 @@ impl MycApp { #[cfg(test)] mod tests { - use std::path::PathBuf; - use crate::host_identity::RadrootsIdentity; - use crate::config::{MycConfig, MycSignerStateBackend}; + use crate::config::MycSignerStateBackend; use super::MycApp; @@ -58,8 +56,7 @@ mod tests { #[test] fn app_bootstrap_preserves_runtime_snapshot() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(temp.path()).join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("identity.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( @@ -74,7 +71,7 @@ mod tests { let app = MycApp::bootstrap(config).expect("bootstrap"); let snapshot = app.snapshot(); - assert!(snapshot.state_dir.ends_with("state")); + assert!(snapshot.state_dir.ends_with("services/myc/test")); assert!(snapshot.audit_dir.ends_with("audit")); assert!( snapshot @@ -112,8 +109,7 @@ mod tests { #[test] fn app_bootstrap_uses_backend_aware_signer_state_path() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(temp.path()).join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("identity.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -202,7 +202,13 @@ impl MycRuntime { let signer_public = self.signer.signer_identity.to_public(); let user_public = self.signer.user_identity.to_public(); MycStartupSnapshot { - instance_name: self.config.service.instance_name.clone(), + instance_name: self + .config + .runtime_context() + .context() + .instance() + .as_str() + .to_owned(), log_filter: self.config.logging.filter.clone(), observability_enabled: self.config.observability.enabled, observability_bind_addr: self.config.observability.bind_addr, @@ -1038,7 +1044,7 @@ impl MycRuntimePaths { } fn from_config(config: &MycConfig) -> Self { - let state_dir = config.paths.state_dir.clone(); + let state_dir = config.paths.state_dir().to_path_buf(); let audit_dir = Self::audit_dir_for_state_dir(&state_dir); Self { signer_identity_path: config.paths.signer_identity_path.clone(), @@ -1306,7 +1312,7 @@ mod tests { use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::host_identity::RadrootsIdentity; @@ -1320,9 +1326,7 @@ mod tests { use super::{MycRuntime, startup_identity_path}; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; - use crate::config::{ - MycConfig, MycIdentityBackend, MycRuntimeAuditBackend, MycSignerStateBackend, - }; + use crate::config::{MycIdentityBackend, MycRuntimeAuditBackend, MycSignerStateBackend}; use crate::discovery::MycDiscoveryContext; use crate::error::MycError; use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus}; @@ -1353,8 +1357,7 @@ mod tests { #[test] fn bootstrap_creates_runtime_directories() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(temp.path()).join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("identity.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( @@ -1418,19 +1421,20 @@ mod tests { #[test] fn bootstrap_rejects_invalid_config() { - let mut config = MycConfig::default(); - config.service.instance_name.clear(); + let mut config = crate::config::test_config(Path::new("/tmp/radroots-myc-invalid")); + config.logging.filter.clear(); let err = match MycRuntime::bootstrap(config) { Ok(_) => panic!("expected invalid config error"), Err(err) => err, }; - assert!(err.to_string().contains("service.instance_name")); + assert!(err.to_string().contains("logging.filter")); } #[test] fn bootstrap_rejects_mismatched_persisted_signer_identity() { let temp = tempfile::tempdir().expect("tempdir"); + let mut config = crate::config::test_config(temp.path()); let identity_path = temp.path().join("identity.json"); let user_path = temp.path().join("user.json"); write_test_identity( @@ -1447,15 +1451,13 @@ mod tests { ) .expect("second identity"); let store = Arc::new(RadrootsNostrFileSignerStore::new( - temp.path().join("state").join("signer-state.json"), + config.paths.state_dir().join("signer-state.json"), )); let manager = RadrootsNostrSignerManager::new(store).expect("manager"); manager .set_signer_identity(store_identity.to_public()) .expect("persist signer"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); config.paths.signer_identity_path = identity_path; config.paths.user_identity_path = user_path; @@ -1469,8 +1471,7 @@ mod tests { #[test] fn bootstrap_keeps_signer_and_user_identities_distinct() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( @@ -1497,8 +1498,7 @@ mod tests { #[test] fn bootstrap_cleans_stale_trusted_authorized_sessions() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); @@ -1555,8 +1555,7 @@ mod tests { #[test] fn bootstrap_prepares_transport_when_enabled() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.transport.enabled = true; @@ -1587,8 +1586,7 @@ mod tests { &helper_path, "1111111111111111111111111111111111111111111111111111111111111111", ); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_backend = MycIdentityBackend::ExternalCommand; config.paths.signer_identity_path = helper_path; config.paths.user_identity_path = temp.path().join("user.json"); @@ -1625,8 +1623,7 @@ mod tests { &helper_path, "6666666666666666666666666666666666666666666666666666666666666666", ); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.discovery.enabled = true; @@ -1659,8 +1656,7 @@ mod tests { #[test] fn bootstrap_supports_sqlite_signer_state_backend() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; @@ -1688,6 +1684,7 @@ mod tests { #[test] fn bootstrap_rejects_mismatched_persisted_sqlite_signer_identity() { let temp = tempfile::tempdir().expect("tempdir"); + let mut config = crate::config::test_config(temp.path()); let identity_path = temp.path().join("identity.json"); let user_path = temp.path().join("user.json"); write_test_identity( @@ -1705,7 +1702,7 @@ mod tests { .expect("second identity"); let store = Arc::new( RadrootsNostrSqliteSignerStore::open( - temp.path().join("state").join("signer-state.sqlite"), + config.paths.state_dir().join("signer-state.sqlite"), ) .expect("open sqlite store"), ); @@ -1714,8 +1711,6 @@ mod tests { .set_signer_identity(store_identity.to_public()) .expect("persist signer"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); config.paths.signer_identity_path = identity_path; config.paths.user_identity_path = user_path; config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; @@ -1730,8 +1725,7 @@ mod tests { #[test] fn bootstrap_supports_sqlite_operation_audit_backend() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; @@ -1772,7 +1766,7 @@ mod tests { #[test] fn startup_identity_path_reporting_matches_backend_sources() { - let mut config = MycConfig::default(); + let mut config = crate::config::test_config(Path::new("/tmp/radroots-myc-reporting")); config.paths.signer_identity_backend = MycIdentityBackend::HostVault; config.paths.signer_identity_keyring_account_id = Some("1111111111111111111111111111111111111111111111111111111111111111".to_owned()); @@ -1800,8 +1794,7 @@ mod tests { #[tokio::test] async fn startup_recovery_rejects_orphaned_signer_publish_workflow() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( @@ -1846,8 +1839,7 @@ mod tests { #[tokio::test] async fn startup_recovery_finalizes_published_connect_secret_workflow() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( @@ -1962,8 +1954,7 @@ mod tests { #[tokio::test] async fn startup_recovery_rejects_queued_job_with_missing_signer_workflow() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( diff --git a/src/cli_v1.rs b/src/cli_v1.rs @@ -6,12 +6,11 @@ use std::fmt; use std::path::{Component, Path, PathBuf}; use clap::{Parser, Subcommand, ValueEnum}; - -/// Maximum encoded length of a Myc instance identifier. -pub const MYC_INSTANCE_ID_MAX_BYTES: usize = 128; +use radroots_runtime_paths::InstanceId; /// The exact bootstrap profile selected by the operator. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum MycBootstrapProfileV1 { ServiceHost, Interactive, @@ -121,7 +120,7 @@ impl Error for MycCliV1Error {} /// A validated one-pass Myc bootstrap and command selection. pub struct MycCliInvocationV1 { profile: MycBootstrapProfileV1, - instance: Box<str>, + instance: InstanceId, repo_local_root: Option<PathBuf>, config_path: Option<PathBuf>, command: MycCommandV1, @@ -136,7 +135,7 @@ impl MycCliInvocationV1 { /// Returns the validated instance identifier. #[must_use] - pub fn instance(&self) -> &str { + pub fn instance(&self) -> &InstanceId { &self.instance } @@ -197,7 +196,8 @@ where let instance = parsed .instance .ok_or_else(|| MycCliV1Error::new(MycCliV1ErrorKind::InvalidArguments))?; - validate_instance(&instance)?; + let instance = InstanceId::new(instance) + .map_err(|_| MycCliV1Error::new(MycCliV1ErrorKind::InvalidInstance))?; validate_bootstrap_paths( profile, parsed.repo_local_root.as_deref(), @@ -206,29 +206,13 @@ where Ok(MycCliInvocationV1 { profile, - instance: instance.into_boxed_str(), + instance, repo_local_root: parsed.repo_local_root, config_path: parsed.config, command: parsed.command.into(), }) } -fn validate_instance(value: &str) -> Result<(), MycCliV1Error> { - let bytes = value.as_bytes(); - let is_boundary = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); - if bytes.is_empty() - || bytes.len() > MYC_INSTANCE_ID_MAX_BYTES - || !is_boundary(bytes[0]) - || !is_boundary(bytes[bytes.len() - 1]) - || !bytes - .iter() - .all(|byte| is_boundary(*byte) || matches!(*byte, b'-' | b'_')) - { - return Err(MycCliV1Error::new(MycCliV1ErrorKind::InvalidInstance)); - } - Ok(()) -} - fn validate_bootstrap_paths( profile: MycBootstrapProfileV1, repo_local_root: Option<&Path>, @@ -484,7 +468,7 @@ mod tests { "run", ]) .expect("production profile"); - assert_eq!(invocation.instance(), "north-01"); + assert_eq!(invocation.instance().as_str(), "north-01"); assert_eq!( invocation.config_path(), Some(Path::new("/etc/radroots/myc.toml")) @@ -543,7 +527,7 @@ mod tests { .kind(), MycCliV1ErrorKind::InvalidInstance ); - let exact = "a".repeat(MYC_INSTANCE_ID_MAX_BYTES); + let exact = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES); assert!( parse_myc_cli_v1_from([ "myc", @@ -555,7 +539,7 @@ mod tests { ]) .is_ok() ); - let overlong = "a".repeat(MYC_INSTANCE_ID_MAX_BYTES + 1); + let overlong = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES + 1); assert_eq!( parse_myc_cli_v1_from([ "myc", diff --git a/src/config.rs b/src/config.rs @@ -1,26 +1,24 @@ use std::collections::BTreeSet; use std::net::SocketAddr; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::nostr_contract::RadrootsNostrRelayUrl; -use crate::paths::{RadrootsPathResolver, RadrootsRuntimePathPolicyContract}; use crate::signer::prelude::RadrootsNostrSignerApprovalRequirement; use nostr::PublicKey; use radroots_nostr_connect::permission::Permissions; use serde::{Deserialize, Serialize}; use tracing_subscriber::EnvFilter; +use crate::MycBootstrapProfileV1; use crate::error::MycError; -use crate::paths::MycPathOverrideFlags; -pub use crate::paths::{MycPathProfile, MycPathsConfig}; +pub use crate::paths::MycPathsConfig; +use crate::runtime_context::MycRuntimeContext; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct MycConfig { - pub service: MycServiceConfig, pub logging: MycLoggingConfig, pub custody: MycCustodyConfig, - pub paths: MycPathsConfig, + pub(crate) paths: MycPathsConfig, pub persistence: MycPersistenceConfig, pub audit: MycAuditConfig, pub observability: MycObservabilityConfig, @@ -31,12 +29,6 @@ pub struct MycConfig { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] -pub struct MycServiceConfig { - pub instance_name: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] pub struct MycLoggingConfig { pub filter: String, pub output_dir: Option<PathBuf>, @@ -160,8 +152,8 @@ pub struct MycIdentitySourceSpec { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycRuntimeContractOutput { - pub active_profile: MycPathProfile, - pub allowed_profiles: Vec<MycPathProfile>, + pub active_profile: MycBootstrapProfileV1, + pub allowed_profiles: Vec<MycBootstrapProfileV1>, pub default_shared_secret_backend: MycIdentityBackend, pub allowed_shared_secret_backends: Vec<MycIdentityBackend>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -171,7 +163,12 @@ pub struct MycRuntimeContractOutput { pub path_overrides: MycRuntimePathOverrideContractOutput, } -pub type MycRuntimePathOverrideContractOutput = RadrootsRuntimePathPolicyContract; +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycRuntimePathOverrideContractOutput { + pub canonical_root_selection: String, + pub canonical_subordinate_path_override: String, + pub leaf_path_env_posture: String, +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -199,25 +196,6 @@ pub struct MycPolicyConfig { pub auth_challenge_rate_limit_max_attempts: Option<usize>, } -impl Default for MycConfig { - fn default() -> Self { - Self::default_with_path_selection( - &RadrootsPathResolver::current(), - MycPathProfile::InteractiveUser, - None, - ) - .expect("current process should resolve myc runtime paths") - } -} - -impl Default for MycServiceConfig { - fn default() -> Self { - Self { - instance_name: "myc".to_owned(), - } - } -} - impl Default for MycLoggingConfig { fn default() -> Self { Self { @@ -352,10 +330,10 @@ impl MycIdentityBackend { } } -const MYC_ALLOWED_PROFILES: [MycPathProfile; 3] = [ - MycPathProfile::InteractiveUser, - MycPathProfile::ServiceHost, - MycPathProfile::RepoLocal, +const MYC_ALLOWED_PROFILES: [MycBootstrapProfileV1; 3] = [ + MycBootstrapProfileV1::Interactive, + MycBootstrapProfileV1::ServiceHost, + MycBootstrapProfileV1::RepoLocal, ]; const MYC_ALLOWED_SHARED_SECRET_BACKENDS: [MycIdentityBackend; 4] = [ MycIdentityBackend::EncryptedFile, @@ -371,7 +349,7 @@ const MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE: &str = "config_document_cli_only" const MYC_LEAF_PATH_ENV_POSTURE: &str = "forbidden"; impl MycRuntimeContractOutput { - pub fn for_active_profile(active_profile: MycPathProfile) -> Self { + pub fn for_active_profile(active_profile: MycBootstrapProfileV1) -> Self { Self { active_profile, allowed_profiles: MYC_ALLOWED_PROFILES.to_vec(), @@ -382,11 +360,12 @@ impl MycRuntimeContractOutput { .map(str::to_owned) .collect(), host_vault_policy: Some(MYC_HOST_VAULT_POLICY.to_owned()), - path_overrides: RadrootsRuntimePathPolicyContract::new( - MYC_CANONICAL_ROOT_SELECTION, - MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE, - MYC_LEAF_PATH_ENV_POSTURE, - ), + path_overrides: MycRuntimePathOverrideContractOutput { + canonical_root_selection: MYC_CANONICAL_ROOT_SELECTION.to_owned(), + canonical_subordinate_path_override: MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE + .to_owned(), + leaf_path_env_posture: MYC_LEAF_PATH_ENV_POSTURE.to_owned(), + }, } } } @@ -410,7 +389,7 @@ impl MycRuntimeAuditBackend { } impl MycConfig { - pub fn allowed_profiles() -> Vec<MycPathProfile> { + pub fn allowed_profiles() -> Vec<MycBootstrapProfileV1> { MYC_ALLOWED_PROFILES.to_vec() } @@ -434,37 +413,49 @@ impl MycConfig { } pub fn runtime_contract_output(&self) -> MycRuntimeContractOutput { - MycRuntimeContractOutput::for_active_profile(self.paths.profile) + MycRuntimeContractOutput::for_active_profile(self.paths.runtime_context().profile()) } - fn default_with_path_selection( - resolver: &RadrootsPathResolver, - profile: MycPathProfile, - repo_local_root: Option<&Path>, - ) -> Result<Self, MycError> { - let mut config = Self { - service: MycServiceConfig::default(), - logging: MycLoggingConfig::default(), + #[must_use] + pub fn from_runtime_context(runtime_context: MycRuntimeContext) -> Self { + let logs_dir = runtime_context.context().paths().logs().to_path_buf(); + let nip05_output_path = runtime_context + .context() + .paths() + .state() + .join("public/.well-known/nostr.json"); + let logging = MycLoggingConfig { + output_dir: Some(logs_dir), + ..MycLoggingConfig::default() + }; + let discovery = MycDiscoveryConfig { + nip05_output_path: Some(nip05_output_path), + ..MycDiscoveryConfig::default() + }; + Self { + logging, custody: MycCustodyConfig::default(), - paths: MycPathsConfig::default_with_path_selection(resolver, profile, repo_local_root)?, + paths: MycPathsConfig::from_runtime_context(runtime_context), persistence: MycPersistenceConfig::default(), audit: MycAuditConfig::default(), observability: MycObservabilityConfig::default(), - discovery: MycDiscoveryConfig::default(), + discovery, policy: MycPolicyConfig::default(), transport: MycTransportConfig::default(), - }; - crate::paths::apply_path_defaults(&mut config, resolver, &MycPathOverrideFlags::default())?; - Ok(config) + } } - pub fn validate(&self) -> Result<(), MycError> { - if self.service.instance_name.trim().is_empty() { - return Err(MycError::InvalidConfig( - "service.instance_name must not be empty".to_owned(), - )); - } + #[must_use] + pub fn runtime_context(&self) -> &MycRuntimeContext { + self.paths.runtime_context() + } + + #[must_use] + pub fn paths(&self) -> &MycPathsConfig { + &self.paths + } + pub fn validate(&self) -> Result<(), MycError> { if self.logging.filter.trim().is_empty() { return Err(MycError::InvalidConfig( "logging.filter must not be empty".to_owned(), @@ -486,12 +477,6 @@ impl MycConfig { )); } - if self.paths.state_dir.as_os_str().is_empty() { - return Err(MycError::InvalidConfig( - "paths.state_dir must not be empty".to_owned(), - )); - } - if self.custody.external_command_timeout_secs == 0 { return Err(MycError::InvalidConfig( "custody.external_command_timeout_secs must be greater than zero".to_owned(), @@ -649,6 +634,33 @@ impl MycConfig { } } +#[cfg(test)] +pub(crate) fn test_config(repo_local_root: &std::path::Path) -> MycConfig { + use std::ffi::OsString; + + use crate::{ + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from, + resolve_myc_runtime_context, + }; + + let invocation = parse_myc_cli_v1_from(vec![ + OsString::from("myc"), + OsString::from("--profile"), + OsString::from("repo-local"), + OsString::from("--instance"), + OsString::from("test"), + OsString::from("--repo-local-root"), + repo_local_root.as_os_str().to_owned(), + OsString::from("run"), + ]) + .expect("test CLI selection"); + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + let context = + resolve_myc_runtime_context(&resolver, &invocation).expect("test runtime context"); + MycConfig::from_runtime_context(context) +} + fn validate_optional_rate_limit( label: &str, window_secs: Option<u64>, @@ -1031,45 +1043,66 @@ fn discovery_host_is_local(host: Option<&str>) -> bool { #[cfg(test)] mod tests { - use crate::paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; - use super::*; + use crate::{ + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from, + resolve_myc_runtime_context, + }; - fn linux_resolver(home: &str) -> RadrootsPathResolver { - RadrootsPathResolver::new( + fn config_for( + resolver: &RadrootsPathResolver, + profile: &str, + instance: &str, + repo_local_root: Option<&str>, + ) -> MycConfig { + let mut arguments = vec!["myc", "--profile", profile, "--instance", instance]; + if let Some(root) = repo_local_root { + arguments.extend(["--repo-local-root", root]); + } + arguments.push("run"); + let invocation = parse_myc_cli_v1_from(arguments).expect("CLI selection"); + let context = resolve_myc_runtime_context(resolver, &invocation).expect("runtime context"); + MycConfig::from_runtime_context(context) + } + + fn default_config() -> MycConfig { + super::test_config(std::path::Path::new("/repo/.local/radroots")) + } + + #[test] + fn interactive_config_is_context_derived() { + let resolver = RadrootsPathResolver::new( RadrootsPlatform::Linux, RadrootsHostEnvironment { - home_dir: Some(PathBuf::from(home)), + home_dir: Some(PathBuf::from("/home/treesap")), + xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")), ..RadrootsHostEnvironment::default() }, - ) - } - - #[test] - fn default_config_is_stable() { - let resolver = linux_resolver("/home/treesap"); - let config = MycConfig::default_with_path_selection( - &resolver, - MycPathProfile::InteractiveUser, - None, - ) - .expect("default config"); - assert_eq!(config.service.instance_name, "myc"); + ); + let config = config_for(&resolver, "interactive", "primary", None); assert_eq!(config.logging.filter, "info,myc=info"); - assert_eq!(config.paths.profile, MycPathProfile::InteractiveUser); - assert_eq!(config.paths.repo_local_root, None); assert_eq!( - config.paths.run_dir, - PathBuf::from("/home/treesap/.radroots/run/services/myc") + config.runtime_context().profile(), + MycBootstrapProfileV1::Interactive + ); + assert_eq!( + config.runtime_context().context().instance().as_str(), + "primary" + ); + assert_eq!( + config.paths.run_dir(), + PathBuf::from("/run/user/1000/radroots/services/myc/primary") ); assert_eq!( config.logging.output_dir, - Some(PathBuf::from("/home/treesap/.radroots/logs/services/myc")) + Some(PathBuf::from( + "/home/treesap/.local/state/radroots/logs/services/myc/primary" + )) ); assert!(config.logging.stdout); assert_eq!( - config.paths.state_dir, - PathBuf::from("/home/treesap/.radroots/data/services/myc/state") + config.paths.state_dir(), + PathBuf::from("/home/treesap/.local/share/radroots/services/myc/primary") ); assert_eq!( config.paths.signer_identity_backend, @@ -1077,7 +1110,9 @@ mod tests { ); assert_eq!( config.paths.signer_identity_path, - PathBuf::from("/home/treesap/.radroots/secrets/services/myc/signer-identity.json") + PathBuf::from( + "/home/treesap/.config/radroots/secrets/services/myc/primary/signer-identity.json" + ) ); assert_eq!(config.paths.signer_identity_keyring_account_id, None); assert_eq!( @@ -1091,7 +1126,9 @@ mod tests { ); assert_eq!( config.paths.user_identity_path, - PathBuf::from("/home/treesap/.radroots/secrets/services/myc/user-identity.json") + PathBuf::from( + "/home/treesap/.config/radroots/secrets/services/myc/primary/user-identity.json" + ) ); assert_eq!(config.paths.user_identity_keyring_account_id, None); assert_eq!( @@ -1144,7 +1181,7 @@ mod tests { assert_eq!( config.discovery.nip05_output_path, Some(PathBuf::from( - "/home/treesap/.radroots/data/services/myc/public/.well-known/nostr.json" + "/home/treesap/.local/share/radroots/services/myc/primary/public/.well-known/nostr.json" )) ); assert!(!config.transport.enabled); @@ -1162,82 +1199,87 @@ mod tests { #[test] fn service_host_profile_uses_canonical_defaults() { - let resolver = linux_resolver("/home/treesap"); - let config = - MycConfig::default_with_path_selection(&resolver, MycPathProfile::ServiceHost, None) - .expect("service-host config"); + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + let config = config_for(&resolver, "service-host", "primary", None); - assert_eq!(config.paths.profile, MycPathProfile::ServiceHost); + assert_eq!( + config.runtime_context().profile(), + MycBootstrapProfileV1::ServiceHost + ); assert_eq!( config.logging.output_dir, - Some(PathBuf::from("/var/log/radroots/services/myc")) + Some(PathBuf::from("/var/log/radroots/services/myc/primary")) ); assert_eq!( - config.paths.run_dir, - PathBuf::from("/run/radroots/services/myc") + config.paths.run_dir(), + PathBuf::from("/run/radroots/services/myc/primary") ); assert_eq!( - config.paths.state_dir, - PathBuf::from("/var/lib/radroots/services/myc/state") + config.paths.state_dir(), + PathBuf::from("/var/lib/radroots/services/myc/primary") ); assert_eq!( config.paths.signer_identity_path, - PathBuf::from("/etc/radroots/secrets/services/myc/signer-identity.json") + PathBuf::from("/etc/radroots/secrets/services/myc/primary/signer-identity.json") ); assert_eq!( config.paths.user_identity_path, - PathBuf::from("/etc/radroots/secrets/services/myc/user-identity.json") + PathBuf::from("/etc/radroots/secrets/services/myc/primary/user-identity.json") ); assert_eq!( config.discovery.nip05_output_path, Some(PathBuf::from( - "/var/lib/radroots/services/myc/public/.well-known/nostr.json" + "/var/lib/radroots/services/myc/primary/public/.well-known/nostr.json" )) ); } #[test] fn repo_local_profile_uses_explicit_repo_local_root() { - let resolver = linux_resolver("/home/treesap"); + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/myc"); - let config = MycConfig::default_with_path_selection( + let config = config_for( &resolver, - MycPathProfile::RepoLocal, - Some(repo_local_root.as_path()), - ) - .expect("repo-local config"); + "repo-local", + "primary", + Some("/repo/.local/radroots/dev/myc"), + ); - assert_eq!(config.paths.profile, MycPathProfile::RepoLocal); - assert_eq!(config.paths.repo_local_root, Some(repo_local_root.clone())); + assert_eq!( + config.runtime_context().profile(), + MycBootstrapProfileV1::RepoLocal + ); assert_eq!( config.logging.output_dir, - Some(repo_local_root.join("logs/services/myc")) + Some(repo_local_root.join("logs/services/myc/primary")) ); assert_eq!( - config.paths.run_dir, - repo_local_root.join("run/services/myc") + config.paths.run_dir(), + repo_local_root.join("run/services/myc/primary") ); assert_eq!( - config.paths.state_dir, - repo_local_root.join("data/services/myc/state") + config.paths.state_dir(), + repo_local_root.join("data/services/myc/primary") ); assert_eq!( config.paths.signer_identity_path, - repo_local_root.join("secrets/services/myc/signer-identity.json") + repo_local_root.join("secrets/services/myc/primary/signer-identity.json") ); assert_eq!( config.paths.user_identity_path, - repo_local_root.join("secrets/services/myc/user-identity.json") + repo_local_root.join("secrets/services/myc/primary/user-identity.json") ); assert_eq!( config.discovery.nip05_output_path, - Some(repo_local_root.join("data/services/myc/public/.well-known/nostr.json")) + Some(repo_local_root.join("data/services/myc/primary/public/.well-known/nostr.json")) ); } #[test] fn validate_rejects_enabled_transport_without_relays() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.transport.enabled = true; let err = config.validate().expect_err("missing relays"); @@ -1246,7 +1288,7 @@ mod tests { #[test] fn validate_rejects_zero_audit_read_limit() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.audit.default_read_limit = 0; let err = config.validate().expect_err("invalid audit read limit"); @@ -1255,7 +1297,7 @@ mod tests { #[test] fn validate_rejects_zero_external_command_timeout() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.custody.external_command_timeout_secs = 0; let err = config.validate().expect_err("invalid custody timeout"); @@ -1267,7 +1309,7 @@ mod tests { #[test] fn validate_rejects_non_loopback_observability_bind_addr() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.observability.enabled = true; config.observability.bind_addr = "0.0.0.0:9460" .parse() @@ -1284,7 +1326,7 @@ mod tests { #[test] fn discovery_validation_requires_domain_and_relays_when_enabled() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.discovery.enabled = true; config.transport.enabled = true; config.transport.relays = vec!["wss://relay.example.com".to_owned()]; @@ -1300,7 +1342,7 @@ mod tests { #[test] fn discovery_validation_allows_localhost_http_nostrconnect_template() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.discovery.enabled = true; config.discovery.domain = Some("localhost".to_owned()); config.discovery.public_relays = vec!["ws://localhost:8080".to_owned()]; @@ -1312,7 +1354,7 @@ mod tests { #[test] fn discovery_validation_rejects_invalid_nostrconnect_template() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.discovery.enabled = true; config.discovery.domain = Some("myc.example.com".to_owned()); config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()]; @@ -1327,7 +1369,7 @@ mod tests { #[test] fn validate_rejects_invalid_delivery_policy_settings() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.transport.enabled = true; config.transport.relays = vec!["wss://relay.example.com".to_owned()]; config.transport.delivery_policy = MycTransportDeliveryPolicy::Quorum; @@ -1353,7 +1395,7 @@ mod tests { #[test] fn validate_rejects_invalid_publish_retry_settings() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.transport.publish_max_attempts = 0; let err = config.validate().expect_err("zero attempts"); assert!(err.to_string().contains("publish_max_attempts")); @@ -1377,7 +1419,7 @@ mod tests { #[test] fn validate_rejects_overlapping_policy_client_lists() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.policy.trusted_client_pubkeys = vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]; config.policy.denied_client_pubkeys = @@ -1391,7 +1433,7 @@ mod tests { #[test] fn validate_requires_auth_url_for_auth_ttl_policy() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.policy.auth_authorized_ttl_secs = Some(60); let err = config.validate().expect_err("missing auth url"); @@ -1400,7 +1442,7 @@ mod tests { #[test] fn validate_requires_complete_rate_limit_pairs() { - let mut config = MycConfig::default(); + let mut config = default_config(); config.policy.connect_rate_limit_window_secs = Some(60); let err = config @@ -1408,7 +1450,7 @@ mod tests { .expect_err("incomplete connect rate limit"); assert!(err.to_string().contains("policy.connect_rate_limit")); - let mut config = MycConfig::default(); + let mut config = default_config(); config.policy.auth_challenge_rate_limit_max_attempts = Some(2); let err = config @@ -1419,10 +1461,10 @@ mod tests { #[test] fn runtime_contract_output_matches_shared_runtime_contract() { - let config = MycConfig::default(); + let config = default_config(); let contract = config.runtime_contract_output(); - assert_eq!(contract.active_profile, MycPathProfile::InteractiveUser); + assert_eq!(contract.active_profile, MycBootstrapProfileV1::RepoLocal); assert_eq!(contract.allowed_profiles, MycConfig::allowed_profiles()); assert_eq!( contract.default_shared_secret_backend, diff --git a/src/control.rs b/src/control.rs @@ -814,8 +814,7 @@ mod tests { F: FnOnce(&mut MycConfig), { let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(&temp).join("state"); + let mut config = crate::config::test_config(PathBuf::from(&temp).as_path()); config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); config.policy.connection_approval = approval; diff --git a/src/discovery.rs b/src/discovery.rs @@ -2122,8 +2122,6 @@ mod tests { use crate::host_identity::RadrootsIdentity; use nostr::JsonUtil; - use crate::config::MycConfig; - use super::{MycDiscoveryContext, build_metadata, verify_bundle, write_pretty_json}; use crate::MycError; use crate::app::MycRuntime; @@ -2135,8 +2133,7 @@ mod tests { fn runtime() -> MycRuntime { let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(&temp).join("state"); + let mut config = crate::config::test_config(PathBuf::from(&temp).as_path()); config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); config.discovery.enabled = true; diff --git a/src/lib.rs b/src/lib.rs @@ -21,6 +21,7 @@ mod outbox_sqlite; mod paths; pub mod persistence; pub mod policy; +mod runtime_context; pub mod signer; mod signing_adapter; pub mod sql; @@ -35,16 +36,15 @@ pub use audit::{ }; pub use audit_sqlite::MycSqliteOperationAuditStore; pub use cli_v1::{ - MYC_INSTANCE_ID_MAX_BYTES, MycBootstrapProfileV1, MycCliInvocationV1, MycCliV1Error, - MycCliV1ErrorKind, MycCommandV1, MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1, - parse_myc_cli_v1_from, + MycBootstrapProfileV1, MycCliInvocationV1, MycCliV1Error, MycCliV1ErrorKind, MycCommandV1, + MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1, parse_myc_cli_v1_from, }; pub use config::{ MycAuditConfig, MycConfig, MycConnectionApproval, MycCustodyConfig, MycDiscoveryConfig, MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig, MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, MycPolicyConfig, - MycRuntimeAuditBackend, MycRuntimeContractOutput, MycServiceConfig, MycSignerStateBackend, - MycTransportConfig, MycTransportDeliveryPolicy, + MycRuntimeAuditBackend, MycRuntimeContractOutput, MycSignerStateBackend, MycTransportConfig, + MycTransportDeliveryPolicy, }; pub use config_v1::{ MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION, @@ -95,4 +95,13 @@ pub use persistence::{ verify_restored_state, }; pub use policy::{MycConnectDecision, MycPolicyContext}; +pub use radroots_runtime_paths::{ + INSTANCE_ID_MAX_BYTES, InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, + RadrootsPathResolver, RadrootsPlatform, RadrootsServiceInstanceArtifacts, RuntimeContext, + RuntimeContextSource, ServiceId, +}; +pub use runtime_context::{ + MycRuntimeContext, MycRuntimeContextError, MycRuntimeContextErrorKind, + resolve_myc_runtime_context, +}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -1662,7 +1662,7 @@ mod tests { }; use crate::app::{MycRuntime, MycRuntimePaths}; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; - use crate::config::{MycConfig, MycRuntimeAuditBackend}; + use crate::config::MycRuntimeAuditBackend; fn write_test_identity(path: &Path, secret_key: &str) { let identity = @@ -1757,8 +1757,7 @@ mod tests { #[test] fn collect_metrics_uses_live_state_after_bootstrap() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( @@ -1824,8 +1823,7 @@ mod tests { }; let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); write_test_identity( @@ -1867,8 +1865,7 @@ mod tests { use crate::signer::prelude::RadrootsNostrSignerBackend; let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); + let mut config = crate::config::test_config(temp.path()); config.paths.signer_identity_path = temp.path().join("signer.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.transport.enabled = true; diff --git a/src/paths.rs b/src/paths.rs @@ -1,362 +1,107 @@ +use core::fmt; use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; - use crate::{ - config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec}, - error::MycError, + config::{MycIdentityBackend, MycIdentitySourceSpec}, + runtime_context::MycRuntimeContext, }; -const DEFAULT_STATE_DIR_NAME: &str = "state"; -const DEFAULT_CUSTODY_DIR_NAME: &str = "custody"; const DEFAULT_SIGNER_IDENTITY_FILE_NAME: &str = "signer-identity.json"; const DEFAULT_USER_IDENTITY_FILE_NAME: &str = "user-identity.json"; -const DEFAULT_DISCOVERY_APP_IDENTITY_FILE_NAME: &str = "discovery-app-identity.json"; -const DEFAULT_SIGNER_MANAGED_ACCOUNT_FILE_NAME: &str = "signer-accounts.json"; -const DEFAULT_USER_MANAGED_ACCOUNT_FILE_NAME: &str = "user-accounts.json"; -const DEFAULT_DISCOVERY_MANAGED_ACCOUNT_FILE_NAME: &str = "discovery-accounts.json"; -const DEFAULT_DISCOVERY_PUBLIC_DIR_NAME: &str = "public"; -const DEFAULT_DISCOVERY_NIP05_RELATIVE_PATH: &str = ".well-known/nostr.json"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum RadrootsPlatform { - Linux, - Macos, - Windows, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RadrootsHostEnvironment { - pub home_dir: Option<PathBuf>, - pub appdata_dir: Option<PathBuf>, - pub localappdata_dir: Option<PathBuf>, - pub programdata_dir: Option<PathBuf>, -} - -impl RadrootsHostEnvironment { - fn current() -> Self { - Self { - home_dir: std::env::var_os("HOME").map(PathBuf::from), - appdata_dir: std::env::var_os("APPDATA").map(PathBuf::from), - localappdata_dir: std::env::var_os("LOCALAPPDATA").map(PathBuf::from), - programdata_dir: std::env::var_os("PROGRAMDATA").map(PathBuf::from), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum RadrootsPathProfile { - InteractiveUser, - ServiceHost, - RepoLocal, - MobileNative, -} - -#[derive(Debug, Clone)] -pub struct RadrootsPathResolver { - platform: RadrootsPlatform, - environment: RadrootsHostEnvironment, -} - -impl RadrootsPathResolver { - pub const fn new(platform: RadrootsPlatform, environment: RadrootsHostEnvironment) -> Self { - Self { - platform, - environment, - } - } - - pub fn current() -> Self { - #[cfg(target_os = "windows")] - let platform = RadrootsPlatform::Windows; - #[cfg(target_os = "macos")] - let platform = RadrootsPlatform::Macos; - #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] - let platform = RadrootsPlatform::Linux; - Self::new(platform, RadrootsHostEnvironment::current()) - } - - fn roots( - &self, - profile: RadrootsPathProfile, - repo_local_root: Option<&Path>, - ) -> Result<RuntimeRoots, String> { - match profile { - RadrootsPathProfile::RepoLocal => repo_local_root - .map(RuntimeRoots::from_base) - .ok_or_else(|| "repo_local requires an explicit root".to_owned()), - RadrootsPathProfile::ServiceHost => match self.platform { - RadrootsPlatform::Linux | RadrootsPlatform::Macos => Ok(RuntimeRoots { - config: PathBuf::from("/etc/radroots"), - data: PathBuf::from("/var/lib/radroots"), - logs: PathBuf::from("/var/log/radroots"), - run: PathBuf::from("/run/radroots"), - secrets: PathBuf::from("/etc/radroots/secrets"), - }), - RadrootsPlatform::Windows => { - let base = self - .environment - .programdata_dir - .as_deref() - .ok_or_else(|| "PROGRAMDATA is required".to_owned())? - .join("Radroots"); - Ok(RuntimeRoots::from_base(&base)) - } - }, - RadrootsPathProfile::InteractiveUser | RadrootsPathProfile::MobileNative => { - match self.platform { - RadrootsPlatform::Linux | RadrootsPlatform::Macos => { - let base = self - .environment - .home_dir - .as_deref() - .ok_or_else(|| "HOME is required".to_owned())? - .join(".radroots"); - Ok(RuntimeRoots::from_base(&base)) - } - RadrootsPlatform::Windows => { - let roaming = self - .environment - .appdata_dir - .as_deref() - .ok_or_else(|| "APPDATA is required".to_owned())? - .join("Radroots"); - let local = self - .environment - .localappdata_dir - .as_deref() - .ok_or_else(|| "LOCALAPPDATA is required".to_owned())? - .join("Radroots"); - Ok(RuntimeRoots { - config: roaming.join("config"), - data: local.join("data"), - logs: local.join("logs"), - run: local.join("run"), - secrets: roaming.join("secrets"), - }) - } - } - } - } - } -} -#[derive(Debug, Clone)] -struct RuntimeRoots { - config: PathBuf, - data: PathBuf, - logs: PathBuf, - run: PathBuf, - secrets: PathBuf, +/// Transitional prototype runtime inputs derived from a sealed Myc context. +/// +/// The fields are crate-private so external callers cannot replace canonical +/// service-instance paths independently of the typed runtime context. Later +/// ordered state/provider checkpoints remove the remaining prototype provider +/// fields rather than promoting them into the hardened configuration contract. +/// +/// ```compile_fail +/// use myc::MycPathsConfig; +/// +/// let _ = MycPathsConfig { +/// runtime_context: todo!(), +/// signer_identity_backend: todo!(), +/// signer_identity_path: todo!(), +/// signer_identity_keyring_account_id: None, +/// signer_identity_keyring_service_name: String::new(), +/// signer_identity_profile_path: None, +/// user_identity_backend: todo!(), +/// user_identity_path: todo!(), +/// user_identity_keyring_account_id: None, +/// user_identity_keyring_service_name: String::new(), +/// user_identity_profile_path: None, +/// }; +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct MycPathsConfig { + pub(crate) runtime_context: MycRuntimeContext, + pub(crate) signer_identity_backend: MycIdentityBackend, + pub(crate) signer_identity_path: PathBuf, + pub(crate) signer_identity_keyring_account_id: Option<String>, + pub(crate) signer_identity_keyring_service_name: String, + pub(crate) signer_identity_profile_path: Option<PathBuf>, + pub(crate) user_identity_backend: MycIdentityBackend, + pub(crate) user_identity_path: PathBuf, + pub(crate) user_identity_keyring_account_id: Option<String>, + pub(crate) user_identity_keyring_service_name: String, + pub(crate) user_identity_profile_path: Option<PathBuf>, } -impl RuntimeRoots { - fn from_base(base: &Path) -> Self { - Self { - config: base.join("config"), - data: base.join("data"), - logs: base.join("logs"), - run: base.join("run"), - secrets: base.join("secrets"), - } - } - - fn service(self, service: &str) -> Self { - Self { - config: self.config.join("services").join(service), - data: self.data.join("services").join(service), - logs: self.logs.join("services").join(service), - run: self.run.join("services").join(service), - secrets: self.secrets.join("services").join(service), - } +impl fmt::Debug for MycPathsConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycPathsConfig([redacted])") } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsRuntimePathSelection { - pub profile: RadrootsPathProfile, - pub repo_local_root: Option<PathBuf>, -} - -impl RadrootsRuntimePathSelection { - pub fn caller(profile: RadrootsPathProfile, repo_local_root: Option<PathBuf>) -> Self { +impl MycPathsConfig { + pub(crate) fn from_runtime_context(runtime_context: MycRuntimeContext) -> Self { + let secrets = runtime_context.context().paths().secrets(); + let signer_identity_path = secrets.join(DEFAULT_SIGNER_IDENTITY_FILE_NAME); + let user_identity_path = secrets.join(DEFAULT_USER_IDENTITY_FILE_NAME); Self { - profile, - repo_local_root, + runtime_context, + signer_identity_backend: MycIdentityBackend::EncryptedFile, + signer_identity_path, + signer_identity_keyring_account_id: None, + signer_identity_keyring_service_name: "org.radroots.myc.signer".to_owned(), + signer_identity_profile_path: None, + user_identity_backend: MycIdentityBackend::EncryptedFile, + user_identity_path, + user_identity_keyring_account_id: None, + user_identity_keyring_service_name: "org.radroots.myc.user".to_owned(), + user_identity_profile_path: None, } } - fn resolve_service_roots( - &self, - resolver: &RadrootsPathResolver, - service: &str, - ) -> Result<RuntimeRoots, String> { - Ok(resolver - .roots(self.profile, self.repo_local_root.as_deref())? - .service(service)) + #[must_use] + pub fn runtime_context(&self) -> &MycRuntimeContext { + &self.runtime_context } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRuntimePathPolicyContract { - pub canonical_root_selection: String, - pub canonical_subordinate_path_override: String, - pub leaf_path_env_posture: String, -} -impl RadrootsRuntimePathPolicyContract { - pub fn new(root: &str, subordinate: &str, leaf: &str) -> Self { - Self { - canonical_root_selection: root.to_owned(), - canonical_subordinate_path_override: subordinate.to_owned(), - leaf_path_env_posture: leaf.to_owned(), - } + #[must_use] + pub fn state_dir(&self) -> &Path { + self.runtime_context.context().paths().state() } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct MycPathsConfig { - pub profile: MycPathProfile, - pub repo_local_root: Option<PathBuf>, - pub run_dir: PathBuf, - pub state_dir: PathBuf, - pub signer_identity_backend: MycIdentityBackend, - pub signer_identity_path: PathBuf, - pub signer_identity_keyring_account_id: Option<String>, - pub signer_identity_keyring_service_name: String, - pub signer_identity_profile_path: Option<PathBuf>, - pub user_identity_backend: MycIdentityBackend, - pub user_identity_path: PathBuf, - pub user_identity_keyring_account_id: Option<String>, - pub user_identity_keyring_service_name: String, - pub user_identity_profile_path: Option<PathBuf>, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MycPathProfile { - #[default] - InteractiveUser, - ServiceHost, - RepoLocal, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycResolvedRuntimePaths { - logs_dir: PathBuf, - run_dir: PathBuf, - state_dir: PathBuf, - signer_identity_path: PathBuf, - user_identity_path: PathBuf, - signer_managed_account_path: PathBuf, - user_managed_account_path: PathBuf, - discovery_app_identity_path: PathBuf, - discovery_managed_account_path: PathBuf, - discovery_nip05_output_path: PathBuf, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct MycPathOverrideFlags { - pub(crate) logging_output_dir: bool, - pub(crate) state_dir: bool, - pub(crate) signer_identity_path: bool, - pub(crate) user_identity_path: bool, - pub(crate) discovery_app_identity_path: bool, - pub(crate) discovery_nip05_output_path: bool, -} - -impl Default for MycPathsConfig { - fn default() -> Self { - Self::default_with_path_selection( - &RadrootsPathResolver::current(), - MycPathProfile::InteractiveUser, - None, - ) - .expect("current process should resolve myc runtime paths") - } -} -impl MycPathProfile { - pub fn as_str(self) -> &'static str { - match self { - Self::InteractiveUser => "interactive_user", - Self::ServiceHost => "service_host", - Self::RepoLocal => "repo_local", - } + #[must_use] + pub fn run_dir(&self) -> &Path { + self.runtime_context.context().paths().run() } - fn into_radroots_profile(self) -> RadrootsPathProfile { - match self { - Self::InteractiveUser => RadrootsPathProfile::InteractiveUser, - Self::ServiceHost => RadrootsPathProfile::ServiceHost, - Self::RepoLocal => RadrootsPathProfile::RepoLocal, - } + #[must_use] + pub fn logs_dir(&self) -> &Path { + self.runtime_context.context().paths().logs() } -} -impl MycResolvedRuntimePaths { - fn resolve( - resolver: &RadrootsPathResolver, - profile: MycPathProfile, - repo_local_root: Option<&Path>, - ) -> Result<Self, MycError> { - let selection = RadrootsRuntimePathSelection::caller( - profile.into_radroots_profile(), - repo_local_root.map(Path::to_path_buf), - ); - let namespaced = selection - .resolve_service_roots(resolver, "myc") - .map_err(|error| { - MycError::InvalidConfig(format!("resolve myc runtime paths: {error}")) - })?; - let custody_dir = namespaced.data.join(DEFAULT_CUSTODY_DIR_NAME); - Ok(Self { - logs_dir: namespaced.logs, - run_dir: namespaced.run, - state_dir: namespaced.data.join(DEFAULT_STATE_DIR_NAME), - signer_identity_path: namespaced.secrets.join(DEFAULT_SIGNER_IDENTITY_FILE_NAME), - user_identity_path: namespaced.secrets.join(DEFAULT_USER_IDENTITY_FILE_NAME), - signer_managed_account_path: custody_dir.join(DEFAULT_SIGNER_MANAGED_ACCOUNT_FILE_NAME), - user_managed_account_path: custody_dir.join(DEFAULT_USER_MANAGED_ACCOUNT_FILE_NAME), - discovery_app_identity_path: namespaced - .secrets - .join(DEFAULT_DISCOVERY_APP_IDENTITY_FILE_NAME), - discovery_managed_account_path: custody_dir - .join(DEFAULT_DISCOVERY_MANAGED_ACCOUNT_FILE_NAME), - discovery_nip05_output_path: namespaced - .data - .join(DEFAULT_DISCOVERY_PUBLIC_DIR_NAME) - .join(DEFAULT_DISCOVERY_NIP05_RELATIVE_PATH), - }) + #[must_use] + pub fn signer_identity_path(&self) -> &Path { + &self.signer_identity_path } -} -impl MycPathsConfig { - pub(crate) fn default_with_path_selection( - resolver: &RadrootsPathResolver, - profile: MycPathProfile, - repo_local_root: Option<&Path>, - ) -> Result<Self, MycError> { - let resolved = MycResolvedRuntimePaths::resolve(resolver, profile, repo_local_root)?; - Ok(Self { - profile, - repo_local_root: repo_local_root.map(Path::to_path_buf), - run_dir: resolved.run_dir, - state_dir: resolved.state_dir, - signer_identity_backend: MycIdentityBackend::EncryptedFile, - signer_identity_path: resolved.signer_identity_path, - signer_identity_keyring_account_id: None, - signer_identity_keyring_service_name: "org.radroots.myc.signer".to_owned(), - signer_identity_profile_path: None, - user_identity_backend: MycIdentityBackend::EncryptedFile, - user_identity_path: resolved.user_identity_path, - user_identity_keyring_account_id: None, - user_identity_keyring_service_name: "org.radroots.myc.user".to_owned(), - user_identity_profile_path: None, - }) + #[must_use] + pub fn user_identity_path(&self) -> &Path { + &self.user_identity_path } pub fn signer_identity_source(&self) -> MycIdentitySourceSpec { @@ -429,58 +174,3 @@ impl MycPathsConfig { } } } - -pub(crate) fn apply_path_defaults( - config: &mut MycConfig, - resolver: &RadrootsPathResolver, - overrides: &MycPathOverrideFlags, -) -> Result<(), MycError> { - let resolved = MycResolvedRuntimePaths::resolve( - resolver, - config.paths.profile, - config.paths.repo_local_root.as_deref(), - )?; - config.paths.run_dir = resolved.run_dir; - if !overrides.logging_output_dir { - config.logging.output_dir = Some(resolved.logs_dir); - } - if !overrides.state_dir { - config.paths.state_dir = resolved.state_dir; - } - if !overrides.signer_identity_path { - config.paths.signer_identity_path = match config.paths.signer_identity_backend { - MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile => { - resolved.signer_identity_path - } - MycIdentityBackend::ManagedAccount => resolved.signer_managed_account_path, - MycIdentityBackend::HostVault => PathBuf::new(), - MycIdentityBackend::ExternalCommand => PathBuf::new(), - }; - } - if !overrides.user_identity_path { - config.paths.user_identity_path = match config.paths.user_identity_backend { - MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile => { - resolved.user_identity_path - } - MycIdentityBackend::ManagedAccount => resolved.user_managed_account_path, - MycIdentityBackend::HostVault => PathBuf::new(), - MycIdentityBackend::ExternalCommand => PathBuf::new(), - }; - } - if !overrides.discovery_app_identity_path { - config.discovery.app_identity_path = match config.discovery.app_identity_backend { - Some(MycIdentityBackend::EncryptedFile) | Some(MycIdentityBackend::PlaintextFile) => { - Some(resolved.discovery_app_identity_path) - } - Some(MycIdentityBackend::ManagedAccount) => { - Some(resolved.discovery_managed_account_path) - } - Some(MycIdentityBackend::HostVault) | None => None, - Some(MycIdentityBackend::ExternalCommand) => None, - }; - } - if !overrides.discovery_nip05_output_path { - config.discovery.nip05_output_path = Some(resolved.discovery_nip05_output_path); - } - Ok(()) -} diff --git a/src/persistence.rs b/src/persistence.rs @@ -252,10 +252,10 @@ pub fn import_json_to_sqlite( ) -> Result<MycPersistenceImportJsonToSqliteOutput, MycError> { config.validate()?; let selection = selection.resolve(config)?; - let state_dir = &config.paths.state_dir; + let state_dir = config.paths.state_dir(); let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); fs::create_dir_all(state_dir).map_err(|source| MycError::CreateDir { - path: state_dir.clone(), + path: state_dir.to_path_buf(), source, })?; fs::create_dir_all(&audit_dir).map_err(|source| MycError::CreateDir { @@ -285,7 +285,7 @@ pub fn backup_persistence( let output_dir = output_dir.as_ref().to_path_buf(); let backup_manifest_path = output_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME); - let state_dir = &config.paths.state_dir; + let state_dir = config.paths.state_dir(); let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); let signer_state_path = MycRuntimePaths::signer_state_path_for_backend( state_dir, @@ -350,7 +350,7 @@ pub fn backup_persistence( backup_dir: output_dir.clone(), manifest_path: backup_manifest_path, state_dir: MycPersistenceBackupStateOutput { - source_path: state_dir.clone(), + source_path: state_dir.to_path_buf(), destination_path: backup_state_dir, file_count: state_files.len(), }, @@ -380,7 +380,7 @@ pub fn restore_backup( ))); } - ensure_restore_state_destination_clear(&config.paths.state_dir)?; + ensure_restore_state_destination_clear(config.paths.state_dir())?; let signer_identity_reference = restore_identity_reference( &backup_dir, &manifest.signer_identity_reference, @@ -404,14 +404,14 @@ pub fn restore_backup( }; let restored_state_files = - copy_dir_recursive_collect(&state_source_dir, &config.paths.state_dir)?; + copy_dir_recursive_collect(&state_source_dir, config.paths.state_dir())?; Ok(MycPersistenceRestoreOutput { backup_dir: backup_dir.clone(), manifest_path: backup_manifest_path, state_dir: MycPersistenceRestoreStateOutput { source_path: state_source_dir, - destination_path: config.paths.state_dir.clone(), + destination_path: config.paths.state_dir().to_path_buf(), file_count: restored_state_files.len(), }, signer_identity_reference, @@ -425,7 +425,7 @@ pub fn verify_restored_state( ) -> Result<MycPersistenceVerifyRestoreOutput, MycError> { config.validate()?; - let state_dir = &config.paths.state_dir; + let state_dir = config.paths.state_dir(); let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir); let signer_state_path = MycRuntimePaths::signer_state_path_for_backend( state_dir, @@ -550,11 +550,11 @@ fn import_signer_state_json_to_sqlite( config: &MycConfig, ) -> Result<MycSignerStateImportOutput, MycError> { let source_path = MycRuntimePaths::signer_state_path_for_backend( - &config.paths.state_dir, + config.paths.state_dir(), MycSignerStateBackend::JsonFile, ); let destination_path = MycRuntimePaths::signer_state_path_for_backend( - &config.paths.state_dir, + config.paths.state_dir(), MycSignerStateBackend::Sqlite, ); let source_store = RadrootsNostrFileSignerStore::new(&source_path); @@ -1519,7 +1519,8 @@ mod tests { } fn load_json_signer_state(temp: &Path) -> RadrootsNostrSignerStoreState { - RadrootsNostrFileSignerStore::new(temp.join("state").join("signer-state.json")) + let config = crate::config::test_config(temp); + RadrootsNostrFileSignerStore::new(config.paths.state_dir().join("signer-state.json")) .load() .expect("load signer state") } @@ -1535,8 +1536,7 @@ mod tests { } fn base_config(temp: &Path) -> MycConfig { - let mut config = MycConfig::default(); - config.paths.state_dir = temp.join("state"); + let mut config = crate::config::test_config(temp); config.paths.signer_identity_path = temp.join("signer.json"); config.paths.user_identity_path = temp.join("user.json"); write_identity(&config.paths.signer_identity_path, SIGNER_SECRET_KEY); @@ -1915,13 +1915,14 @@ mod tests { sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; let sqlite_store = RadrootsNostrSqliteSignerStore::open( - temp.path().join("state").join("signer-state.sqlite"), + sqlite_config.paths.state_dir().join("signer-state.sqlite"), ) .expect("sqlite store"); - let existing_state = - RadrootsNostrFileSignerStore::new(temp.path().join("state").join("signer-state.json")) - .load() - .expect("load source state"); + let existing_state = RadrootsNostrFileSignerStore::new( + sqlite_config.paths.state_dir().join("signer-state.json"), + ) + .load() + .expect("load source state"); sqlite_store .save(&existing_state) .expect("save sqlite state"); @@ -1953,7 +1954,7 @@ mod tests { sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; let sqlite_audit_store = MycSqliteOperationAuditStore::open( - temp.path().join("state").join("audit"), + sqlite_config.paths.state_dir().join("audit"), sqlite_config.audit.clone(), ) .expect("sqlite audit store"); diff --git a/src/runtime_context.rs b/src/runtime_context.rs @@ -0,0 +1,163 @@ +//! Sealed bootstrap binding for one canonical Myc service instance. + +use core::fmt; +use std::{error::Error, path::Path}; + +use radroots_runtime_paths::{ + RadrootsPathProfile, RadrootsPathResolver, RadrootsServiceInstanceArtifacts, RuntimeContext, + RuntimeContextBootstrap, RuntimeContextSource, ServiceId, default_service_instance_artifacts, +}; + +use crate::{MycBootstrapProfileV1, MycCliInvocationV1}; + +const MYC_SERVICE_ID: &str = "myc"; + +/// Stable source-free classification for Myc runtime-context failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycRuntimeContextErrorKind { + InvalidServiceIdentity, + InvalidBootstrapBinding, + PathSelection, +} + +impl MycRuntimeContextErrorKind { + const fn message(self) -> &'static str { + match self { + Self::InvalidServiceIdentity => "Myc service identity is invalid", + Self::InvalidBootstrapBinding => "Myc bootstrap selectors are inconsistent", + Self::PathSelection => "Myc runtime path selection failed", + } + } +} + +/// One redacted Myc runtime-context failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycRuntimeContextError { + kind: MycRuntimeContextErrorKind, +} + +impl MycRuntimeContextError { + const fn new(kind: MycRuntimeContextErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> MycRuntimeContextErrorKind { + self.kind + } +} + +impl fmt::Debug for MycRuntimeContextError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycRuntimeContextError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycRuntimeContextError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycRuntimeContextError {} + +/// Immutable canonical paths and bootstrap selection for one Myc instance. +/// +/// Construction is sealed to the validated CLI invocation and the shared +/// runtime-path resolver. Callers cannot forge an alternate service identity, +/// path set, artifact name, or selected configuration path: +/// +/// ```compile_fail +/// use myc::MycRuntimeContext; +/// +/// let _ = MycRuntimeContext { +/// context: todo!(), +/// artifacts: todo!(), +/// selected_config_path: todo!(), +/// profile: todo!(), +/// }; +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct MycRuntimeContext { + context: RuntimeContext, + artifacts: RadrootsServiceInstanceArtifacts, + selected_config_path: std::path::PathBuf, + profile: MycBootstrapProfileV1, +} + +impl MycRuntimeContext { + #[must_use] + pub fn context(&self) -> &RuntimeContext { + &self.context + } + + #[must_use] + pub fn artifacts(&self) -> &RadrootsServiceInstanceArtifacts { + &self.artifacts + } + + #[must_use] + pub fn selected_config_path(&self) -> &Path { + &self.selected_config_path + } + + #[must_use] + pub const fn profile(&self) -> MycBootstrapProfileV1 { + self.profile + } +} + +impl fmt::Debug for MycRuntimeContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycRuntimeContext") + .field("profile", &self.profile) + .field("service", &MYC_SERVICE_ID) + .field("instance", &"[redacted]") + .field("paths", &"[redacted]") + .finish() + } +} + +/// Resolves one validated CLI selection into the sole Myc path authority. +pub fn resolve_myc_runtime_context( + resolver: &RadrootsPathResolver, + invocation: &MycCliInvocationV1, +) -> Result<MycRuntimeContext, MycRuntimeContextError> { + let profile = invocation.profile(); + let path_profile = match profile { + MycBootstrapProfileV1::ServiceHost => RadrootsPathProfile::ServiceHost, + MycBootstrapProfileV1::Interactive => RadrootsPathProfile::InteractiveUser, + MycBootstrapProfileV1::RepoLocal => RadrootsPathProfile::RepoLocal, + }; + let bootstrap = RuntimeContextBootstrap::new( + path_profile, + invocation.repo_local_root().map(Path::to_path_buf), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .map_err(|_| { + MycRuntimeContextError::new(MycRuntimeContextErrorKind::InvalidBootstrapBinding) + })?; + let service = ServiceId::new(MYC_SERVICE_ID).map_err(|_| { + MycRuntimeContextError::new(MycRuntimeContextErrorKind::InvalidServiceIdentity) + })?; + let context = + RuntimeContext::resolve(resolver, bootstrap, service, invocation.instance().clone()) + .map_err(|_| MycRuntimeContextError::new(MycRuntimeContextErrorKind::PathSelection))?; + let artifacts = default_service_instance_artifacts(context.paths()); + let selected_config_path = invocation + .config_path() + .map(Path::to_path_buf) + .unwrap_or_else(|| artifacts.config().to_path_buf()); + + Ok(MycRuntimeContext { + context, + artifacts, + selected_config_path, + profile, + }) +} diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -861,8 +861,7 @@ mod tests { F: FnOnce(&mut MycConfig), { let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.join("state"); + let mut config = crate::config::test_config(&temp); config.paths.signer_identity_path = temp.join("signer.json"); config.paths.user_identity_path = temp.join("user.json"); config.policy.connection_approval = approval; diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -27,6 +27,13 @@ fn service_host_is_the_exact_default_feature_profile() { } #[test] +fn shared_runtime_paths_is_exactly_pinned_to_the_source_locked_lib() { + assert!(MANIFEST.contains( + "radroots_runtime_paths = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }" + )); +} + +#[test] fn release_acceptance_checks_both_feature_profiles() { assert!( RELEASE_ACCEPTANCE.contains("cargo check --locked --all-targets --no-default-features\n") diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -49,6 +49,8 @@ use tokio::sync::{Mutex, Notify, mpsc, oneshot}; use tokio::time::{Instant, sleep, timeout}; use tokio_tungstenite::tungstenite::Message; +mod support; + type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { @@ -469,21 +471,20 @@ impl MycTestRuntime { F: FnOnce(&mut MycConfig), { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); + let mut config = support::repo_local_config(temp.path()); config.policy.connection_approval = approval; config.transport.enabled = true; config.transport.connect_timeout_secs = 1; config.transport.relays = relay_urls.iter().map(|relay| (*relay).to_owned()).collect(); + let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); + let user_identity_path = config.paths().user_identity_path().to_path_buf(); configure(&mut config); write_identity( - &config.paths.signer_identity_path, + &signer_identity_path, "1111111111111111111111111111111111111111111111111111111111111111", ); write_identity( - &config.paths.user_identity_path, + &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); @@ -507,10 +508,7 @@ impl MycTestRuntime { connect_timeout_secs: u64, ) -> Self { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); + let mut config = support::repo_local_config(temp.path()); config.policy.connection_approval = approval; config.transport.connect_timeout_secs = connect_timeout_secs; config.discovery.enabled = true; @@ -522,12 +520,14 @@ impl MycTestRuntime { config.discovery.nostrconnect_url_template = Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned()); config.discovery.app_identity_path = Some(temp.path().join("app.json")); + let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); + let user_identity_path = config.paths().user_identity_path().to_path_buf(); write_identity( - &config.paths.signer_identity_path, + &signer_identity_path, "1111111111111111111111111111111111111111111111111111111111111111", ); write_identity( - &config.paths.user_identity_path, + &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); write_identity( diff --git a/tests/operability_e2e.rs b/tests/operability_e2e.rs @@ -19,6 +19,8 @@ use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::time::sleep; +mod support; + type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; struct TestRelay { @@ -142,17 +144,16 @@ where F: FnOnce(&mut MycConfig), { let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(&temp).join("state"); - config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); - config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); + let mut config = support::repo_local_config(PathBuf::from(&temp).as_path()); config.transport.connect_timeout_secs = 1; + let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); + let user_identity_path = config.paths().user_identity_path().to_path_buf(); write_test_identity( - &config.paths.signer_identity_path, + &signer_identity_path, "1111111111111111111111111111111111111111111111111111111111111111", ); write_test_identity( - &config.paths.user_identity_path, + &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); configure(&mut config); diff --git a/tests/operability_server.rs b/tests/operability_server.rs @@ -10,6 +10,8 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::sync::oneshot; use tokio::time::{sleep, timeout}; +mod support; + type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; const HTTP_READY_TIMEOUT: Duration = Duration::from_secs(15); @@ -127,19 +129,18 @@ where { let temp = tempfile::tempdir().expect("tempdir").keep(); let bind_addr = free_loopback_addr(); - let mut config = MycConfig::default(); - config.paths.state_dir = PathBuf::from(&temp).join("state"); - config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); - config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); + let mut config = support::repo_local_config(PathBuf::from(&temp).as_path()); config.transport.connect_timeout_secs = 1; config.observability.enabled = true; config.observability.bind_addr = bind_addr; + let signer_identity_path = config.paths().signer_identity_path().to_path_buf(); + let user_identity_path = config.paths().user_identity_path().to_path_buf(); write_test_identity( - &config.paths.signer_identity_path, + &signer_identity_path, "1111111111111111111111111111111111111111111111111111111111111111", ); write_test_identity( - &config.paths.user_identity_path, + &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); configure(&mut config); diff --git a/tests/services_hardening_cli.rs b/tests/services_hardening_cli.rs @@ -4,7 +4,7 @@ use std::error::Error; use std::path::Path; use myc::{ - MYC_INSTANCE_ID_MAX_BYTES, MycBootstrapProfileV1, MycCliV1ErrorKind, MycCommandV1, + INSTANCE_ID_MAX_BYTES, MycBootstrapProfileV1, MycCliV1ErrorKind, MycCommandV1, MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1, parse_myc_cli_v1_from, }; @@ -117,7 +117,7 @@ fn root_api_exposes_validated_cross_bound_bootstrap_values() { ]) .expect("repo-local invocation"); assert_eq!(invocation.profile(), MycBootstrapProfileV1::RepoLocal); - assert_eq!(invocation.instance(), "dev-01"); + assert_eq!(invocation.instance().as_str(), "dev-01"); assert_eq!( invocation.repo_local_root(), Some(Path::new("/repo/radroots")) @@ -126,7 +126,7 @@ fn root_api_exposes_validated_cross_bound_bootstrap_values() { invocation.config_path(), Some(Path::new("/repo/radroots/config/services/myc/config.toml")) ); - assert_eq!(MYC_INSTANCE_ID_MAX_BYTES, 128); + assert_eq!(INSTANCE_ID_MAX_BYTES, 128); } #[test] diff --git a/tests/services_hardening_runtime_context.rs b/tests/services_hardening_runtime_context.rs @@ -0,0 +1,328 @@ +#![forbid(unsafe_code)] + +use std::error::Error; +use std::path::{Path, PathBuf}; + +use myc::{ + MycBootstrapProfileV1, MycConfig, MycRuntimeContextErrorKind, RadrootsHostEnvironment, + RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RuntimeContextSource, + parse_myc_cli_v1_from, resolve_myc_runtime_context, +}; + +const MANIFEST: &str = include_str!("../Cargo.toml"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const PATHS_SOURCE: &str = include_str!("../src/paths.rs"); +const CONFIG_SOURCE: &str = include_str!("../src/config.rs"); +const CONTEXT_SOURCE: &str = include_str!("../src/runtime_context.rs"); + +fn resolve( + resolver: &RadrootsPathResolver, + profile: &str, + instance: &str, + repo_local_root: Option<&str>, + config_path: Option<&str>, +) -> myc::MycRuntimeContext { + let mut arguments = vec!["myc", "--profile", profile, "--instance", instance]; + if let Some(root) = repo_local_root { + arguments.extend(["--repo-local-root", root]); + } + if let Some(path) = config_path { + arguments.extend(["--config", path]); + } + arguments.push("run"); + let invocation = parse_myc_cli_v1_from(arguments).expect("validated CLI selection"); + resolve_myc_runtime_context(resolver, &invocation).expect("Myc runtime context") +} + +fn assert_exact_roots(context: &myc::MycRuntimeContext, expected: [&str; 6]) { + let paths = context.context().paths(); + assert_eq!(paths.config(), Path::new(expected[0])); + assert_eq!(paths.state(), Path::new(expected[1])); + assert_eq!(paths.cache(), Path::new(expected[2])); + assert_eq!(paths.logs(), Path::new(expected[3])); + assert_eq!(paths.run(), Path::new(expected[4])); + assert_eq!(paths.secrets(), Path::new(expected[5])); +} + +#[test] +fn repo_local_context_binds_typed_identity_sources_and_exact_artifacts() { + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + let primary = resolve( + &resolver, + "repo-local", + "primary", + Some("/repo/.local/radroots"), + None, + ); + let secondary = resolve( + &resolver, + "repo-local", + "secondary", + Some("/repo/.local/radroots"), + None, + ); + + assert_eq!(primary.context().service().as_str(), "myc"); + assert_eq!(primary.context().instance().as_str(), "primary"); + assert_eq!(primary.profile(), MycBootstrapProfileV1::RepoLocal); + assert_eq!(primary.context().profile(), RadrootsPathProfile::RepoLocal); + assert_eq!( + primary.context().sources().service(), + RuntimeContextSource::SafeDefault + ); + assert_eq!( + primary.context().sources().instance(), + RuntimeContextSource::BootstrapCli + ); + assert_eq!( + primary.context().sources().profile(), + RuntimeContextSource::BootstrapCli + ); + assert_eq!( + primary.context().sources().repo_local_root(), + Some(RuntimeContextSource::BootstrapCli) + ); + assert_eq!( + primary.context().sources().paths(), + RuntimeContextSource::DerivedPath + ); + assert_exact_roots( + &primary, + [ + "/repo/.local/radroots/config/services/myc/primary", + "/repo/.local/radroots/data/services/myc/primary", + "/repo/.local/radroots/cache/services/myc/primary", + "/repo/.local/radroots/logs/services/myc/primary", + "/repo/.local/radroots/run/services/myc/primary", + "/repo/.local/radroots/secrets/services/myc/primary", + ], + ); + assert_eq!( + primary.artifacts().config(), + Path::new("/repo/.local/radroots/config/services/myc/primary/config.toml") + ); + assert_eq!( + primary.artifacts().state_database(), + Path::new("/repo/.local/radroots/data/services/myc/primary/state.sqlite") + ); + assert_eq!( + primary.artifacts().state_lock(), + Path::new("/repo/.local/radroots/data/services/myc/primary/state.lock") + ); + assert_eq!( + primary.artifacts().admin_socket(), + Path::new("/repo/.local/radroots/run/services/myc/primary/admin.sock") + ); + assert_eq!(primary.selected_config_path(), primary.artifacts().config()); + assert_ne!(primary.context().paths(), secondary.context().paths()); + assert_eq!(secondary.context().instance().as_str(), "secondary"); +} + +#[test] +fn service_host_and_interactive_profiles_use_the_shared_exact_roots() { + let service_host = resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + "service-host", + "primary", + None, + None, + ); + assert_exact_roots( + &service_host, + [ + "/etc/radroots/services/myc/primary", + "/var/lib/radroots/services/myc/primary", + "/var/cache/radroots/services/myc/primary", + "/var/log/radroots/services/myc/primary", + "/run/radroots/services/myc/primary", + "/etc/radroots/secrets/services/myc/primary", + ], + ); + assert_eq!( + service_host.artifacts().admin_socket(), + Path::new("/run/radroots/services/myc/primary/admin.sock") + ); + + let linux_interactive = resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/home/operator")), + xdg_config_home: Some(PathBuf::from("/xdg/config")), + xdg_data_home: Some(PathBuf::from("/xdg/data")), + xdg_state_home: Some(PathBuf::from("/xdg/state")), + xdg_cache_home: Some(PathBuf::from("/xdg/cache")), + xdg_runtime_dir: Some(PathBuf::from("/xdg/run")), + ..RadrootsHostEnvironment::default() + }, + ), + "interactive", + "primary", + None, + None, + ); + assert_exact_roots( + &linux_interactive, + [ + "/xdg/config/radroots/services/myc/primary", + "/xdg/data/radroots/services/myc/primary", + "/xdg/cache/radroots/services/myc/primary", + "/xdg/state/radroots/logs/services/myc/primary", + "/xdg/run/radroots/services/myc/primary", + "/xdg/config/radroots/secrets/services/myc/primary", + ], + ); +} + +#[test] +fn interactive_macos_and_windows_roots_remain_exact_and_injected() { + let macos = resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Macos, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/Users/operator")), + ..RadrootsHostEnvironment::default() + }, + ), + "interactive", + "primary", + None, + None, + ); + assert_exact_roots( + &macos, + [ + "/Users/operator/Library/Application Support/Radroots/config/services/myc/primary", + "/Users/operator/Library/Application Support/Radroots/data/services/myc/primary", + "/Users/operator/Library/Caches/Radroots/services/myc/primary", + "/Users/operator/Library/Logs/Radroots/services/myc/primary", + "/Users/operator/Library/Application Support/Radroots/run/services/myc/primary", + "/Users/operator/Library/Application Support/Radroots/secrets/services/myc/primary", + ], + ); + + let windows = resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Windows, + RadrootsHostEnvironment { + appdata_dir: Some(PathBuf::from(r"C:\Users\operator\AppData\Roaming")), + localappdata_dir: Some(PathBuf::from(r"C:\Users\operator\AppData\Local")), + ..RadrootsHostEnvironment::default() + }, + ), + "interactive", + "primary", + None, + None, + ); + assert_exact_roots( + &windows, + [ + r"C:\Users\operator\AppData\Roaming/Radroots/config/services/myc/primary", + r"C:\Users\operator\AppData\Local/Radroots/data/services/myc/primary", + r"C:\Users\operator\AppData\Local/Radroots/cache/services/myc/primary", + r"C:\Users\operator\AppData\Local/Radroots/logs/services/myc/primary", + r"C:\Users\operator\AppData\Local/Radroots/run/services/myc/primary", + r"C:\Users\operator\AppData\Roaming/Radroots/secrets/services/myc/primary", + ], + ); +} + +#[test] +fn explicit_config_path_overrides_only_the_common_config_artifact_selection() { + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + let context = resolve( + &resolver, + "repo-local", + "primary", + Some("/repo/.local/radroots"), + Some("/operator/config/myc.toml"), + ); + assert_eq!( + context.selected_config_path(), + Path::new("/operator/config/myc.toml") + ); + assert_eq!( + context.artifacts().config(), + Path::new("/repo/.local/radroots/config/services/myc/primary/config.toml") + ); +} + +#[test] +fn unsupported_profile_platform_and_diagnostics_fail_safely() { + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "service-host", + "--instance", + "secret-instance", + "--config", + "/private/secret-config.toml", + "run", + ]) + .expect("valid CLI"); + let error = resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Macos, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect_err("unsupported service-host platform"); + assert_eq!(error.kind(), MycRuntimeContextErrorKind::PathSelection); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("secret-instance")); + assert!(!rendered.contains("secret-config")); + + let context = resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + "repo-local", + "secret-instance", + Some("/private/secret-root"), + Some("/private/secret-config.toml"), + ); + let config = MycConfig::from_runtime_context(context.clone()); + for debug in [format!("{context:?}"), format!("{:?}", config.paths())] { + assert!(!debug.contains("secret-instance")); + assert!(!debug.contains("secret-root")); + assert!(!debug.contains("secret-config")); + } +} + +#[test] +fn shared_runtime_paths_are_the_only_path_policy_and_identity_authority() { + assert!(MANIFEST.contains( + "radroots_runtime_paths = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }" + )); + assert!(LIB_SOURCE.contains("mod runtime_context;")); + assert!(LIB_SOURCE.contains("mod paths;")); + assert!(!LIB_SOURCE.contains("pub mod runtime_context;")); + assert!(!LIB_SOURCE.contains("pub mod paths;")); + assert!(CONTEXT_SOURCE.contains("RuntimeContext::resolve(")); + assert!(CONTEXT_SOURCE.contains("default_service_instance_artifacts(")); + + for forbidden in [ + "struct RadrootsHostEnvironment", + "enum RadrootsPlatform", + "enum RadrootsPathProfile", + "struct RadrootsPathResolver", + "struct RadrootsRuntimePathSelection", + "struct RuntimeRoots", + "std::env::", + "var_os(", + "worker_path", + "worker_namespace", + "apply_path_defaults", + "default_with_path_selection", + ] { + assert!(!PATHS_SOURCE.contains(forbidden), "found `{forbidden}`"); + } + for forbidden in [ + "impl Default for MycConfig", + "struct MycServiceConfig", + "service.instance_name", + "MYC_INSTANCE_ID_MAX_BYTES", + ] { + assert!(!CONFIG_SOURCE.contains(forbidden), "found `{forbidden}`"); + } +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -0,0 +1,25 @@ +use std::{ffi::OsString, path::Path}; + +use myc::{ + MycConfig, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, + parse_myc_cli_v1_from, resolve_myc_runtime_context, +}; + +pub fn repo_local_config(root: &Path) -> MycConfig { + let invocation = parse_myc_cli_v1_from(vec![ + OsString::from("myc"), + OsString::from("--profile"), + OsString::from("repo-local"), + OsString::from("--instance"), + OsString::from("test"), + OsString::from("--repo-local-root"), + root.as_os_str().to_owned(), + OsString::from("run"), + ]) + .expect("test CLI selection"); + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + let context = + resolve_myc_runtime_context(&resolver, &invocation).expect("test runtime context"); + MycConfig::from_runtime_context(context) +}