myc

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

commit f543a51540453b7db735c3e4e4b2a2edf76c3078
parent 9ba2411d5dab85f4e033bf3f4c8bf7b01ef8a5f3
Author: triesap <tyson@radroots.org>
Date:   Thu,  9 Apr 2026 03:37:14 +0000

myc: extract runtime paths module

Diffstat:
Msrc/config.rs | 391++++---------------------------------------------------------------------------
Msrc/custody.rs | 6++----
Msrc/lib.rs | 5+++--
Asrc/paths.rs | 385+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/operability_cli.rs | 19++++++++++++++++---
5 files changed, 424 insertions(+), 382 deletions(-)

diff --git a/src/config.rs b/src/config.rs @@ -7,25 +7,13 @@ use nostr::PublicKey; use radroots_nostr::prelude::RadrootsNostrRelayUrl; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; use radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement; -use radroots_runtime_paths::{ - RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace, -}; +use radroots_runtime_paths::RadrootsPathResolver; use serde::{Deserialize, Serialize}; use tracing_subscriber::EnvFilter; use crate::error::MycError; - -pub const DEFAULT_ENV_PATH: &str = "config.env"; -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"; +use crate::paths::MycPathOverrideFlags; +pub use crate::paths::{DEFAULT_ENV_PATH, MycPathProfile, MycPathsConfig}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -64,26 +52,6 @@ pub struct MycCustodyConfig { #[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 config_env_path: 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, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] pub struct MycPersistenceConfig { pub signer_state_backend: MycSignerStateBackend, pub runtime_audit_backend: MycRuntimeAuditBackend, @@ -165,14 +133,6 @@ pub enum MycIdentityBackend { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum MycPathProfile { - InteractiveUser, - ServiceHost, - RepoLocal, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] pub enum MycSignerStateBackend { JsonFile, Sqlite, @@ -185,31 +145,6 @@ pub enum MycRuntimeAuditBackend { Sqlite, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct MycResolvedRuntimePaths { - config_env_path: PathBuf, - 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)] -struct MycPathOverrideFlags { - logging_output_dir: bool, - state_dir: bool, - signer_identity_path: bool, - user_identity_path: bool, - discovery_app_identity_path: bool, - discovery_nip05_output_path: bool, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MycIdentitySourceSpec { pub backend: MycIdentityBackend, @@ -298,17 +233,6 @@ impl Default for MycCustodyConfig { } } -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 Default for MycTransportConfig { fn default() -> Self { Self { @@ -412,12 +336,6 @@ impl Default for MycIdentityBackend { } } -impl Default for MycPathProfile { - fn default() -> Self { - Self::InteractiveUser - } -} - impl MycConnectionApproval { pub fn into_signer_approval_requirement(self) -> RadrootsNostrSignerApprovalRequirement { match self { @@ -498,172 +416,6 @@ impl MycRuntimeAuditBackend { } } -impl MycPathProfile { - pub fn as_str(self) -> &'static str { - match self { - Self::InteractiveUser => "interactive_user", - Self::ServiceHost => "service_host", - Self::RepoLocal => "repo_local", - } - } - - fn into_radroots_profile(self) -> RadrootsPathProfile { - match self { - Self::InteractiveUser => RadrootsPathProfile::InteractiveUser, - Self::ServiceHost => RadrootsPathProfile::ServiceHost, - Self::RepoLocal => RadrootsPathProfile::RepoLocal, - } - } -} - -impl MycResolvedRuntimePaths { - fn resolve( - resolver: &RadrootsPathResolver, - profile: MycPathProfile, - repo_local_root: Option<&Path>, - ) -> Result<Self, MycError> { - let overrides = match profile { - MycPathProfile::InteractiveUser | MycPathProfile::ServiceHost => { - RadrootsPathOverrides::default() - } - MycPathProfile::RepoLocal => { - let repo_local_root = repo_local_root.ok_or_else(|| { - MycError::InvalidConfig( - "paths.repo_local_root must be set when paths.profile is `repo_local`" - .to_owned(), - ) - })?; - RadrootsPathOverrides::repo_local(repo_local_root) - } - }; - let namespace = RadrootsRuntimeNamespace::service("myc") - .map_err(|error| MycError::InvalidConfig(format!("resolve myc namespace: {error}")))?; - let namespaced = resolver - .resolve(profile.into_radroots_profile(), &overrides) - .map_err(|error| { - MycError::InvalidConfig(format!("resolve myc runtime paths: {error}")) - })? - .namespaced(&namespace); - let custody_dir = namespaced.data.join(DEFAULT_CUSTODY_DIR_NAME); - Ok(Self { - config_env_path: namespaced.config.join(DEFAULT_ENV_PATH), - 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), - }) - } -} - -impl MycPathsConfig { - 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), - config_env_path: resolved.config_env_path, - 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, - }) - } - - pub fn signer_identity_source(&self) -> MycIdentitySourceSpec { - MycIdentitySourceSpec { - backend: self.signer_identity_backend, - path: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => Some(self.signer_identity_path.clone()), - MycIdentityBackend::HostVault => None, - }, - keyring_account_id: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.signer_identity_keyring_account_id.clone(), - }, - keyring_service_name: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { - Some(self.signer_identity_keyring_service_name.clone()) - } - }, - profile_path: match self.signer_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.signer_identity_profile_path.clone(), - }, - } - } - - pub fn user_identity_source(&self) -> MycIdentitySourceSpec { - MycIdentitySourceSpec { - backend: self.user_identity_backend, - path: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => Some(self.user_identity_path.clone()), - MycIdentityBackend::HostVault => None, - }, - keyring_account_id: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.user_identity_keyring_account_id.clone(), - }, - keyring_service_name: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { - Some(self.user_identity_keyring_service_name.clone()) - } - }, - profile_path: match self.user_identity_backend { - MycIdentityBackend::EncryptedFile - | MycIdentityBackend::PlaintextFile - | MycIdentityBackend::ManagedAccount - | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::HostVault => self.user_identity_profile_path.clone(), - }, - } - } -} - impl MycConfig { pub fn allowed_profiles() -> Vec<MycPathProfile> { MYC_ALLOWED_PROFILES.to_vec() @@ -709,27 +461,12 @@ impl MycConfig { policy: MycPolicyConfig::default(), transport: MycTransportConfig::default(), }; - config.apply_path_defaults(resolver, &MycPathOverrideFlags::default())?; + crate::paths::apply_path_defaults(&mut config, resolver, &MycPathOverrideFlags::default())?; Ok(config) } fn process_path_selection() -> Result<(MycPathProfile, Option<PathBuf>), MycError> { - let profile = match std::env::var("MYC_PATHS_PROFILE") { - Ok(value) => parse_path_profile_env( - "MYC_PATHS_PROFILE", - value.as_str(), - Path::new("<process-env>"), - 0, - )?, - Err(std::env::VarError::NotPresent) => MycPathProfile::InteractiveUser, - Err(std::env::VarError::NotUnicode(_)) => { - return Err(MycError::InvalidConfig( - "MYC_PATHS_PROFILE must be valid utf-8 when set".to_owned(), - )); - } - }; - let repo_local_root = std::env::var_os("MYC_PATHS_REPO_LOCAL_ROOT").map(PathBuf::from); - Ok((profile, repo_local_root)) + crate::paths::process_path_selection() } fn default_env_path_with_path_selection( @@ -737,7 +474,7 @@ impl MycConfig { profile: MycPathProfile, repo_local_root: Option<&Path>, ) -> Result<PathBuf, MycError> { - Ok(MycResolvedRuntimePaths::resolve(resolver, profile, repo_local_root)?.config_env_path) + crate::paths::default_env_path_with_path_selection(resolver, profile, repo_local_root) } pub fn load_from_default_env_path() -> Result<Self, MycError> { @@ -1295,70 +1032,14 @@ impl MycConfig { Ok(()) } - fn apply_path_defaults( - &mut self, - resolver: &RadrootsPathResolver, - overrides: &MycPathOverrideFlags, - ) -> Result<(), MycError> { - let resolved = MycResolvedRuntimePaths::resolve( - resolver, - self.paths.profile, - self.paths.repo_local_root.as_deref(), - )?; - self.paths.config_env_path = resolved.config_env_path; - self.paths.run_dir = resolved.run_dir; - if !overrides.logging_output_dir { - self.logging.output_dir = Some(resolved.logs_dir); - } - if !overrides.state_dir { - self.paths.state_dir = resolved.state_dir; - } - if !overrides.signer_identity_path { - self.paths.signer_identity_path = match self.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 { - self.paths.user_identity_path = match self.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 { - self.discovery.app_identity_path = match self.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 { - self.discovery.nip05_output_path = Some(resolved.discovery_nip05_output_path); - } - Ok(()) - } - fn from_env_str_with_source_and_resolver( value: &str, path: &Path, resolver: &RadrootsPathResolver, ) -> Result<Self, MycError> { let entries = parse_env_entries(value, path)?; - let (profile, repo_local_root) = path_selection_from_entries(entries.as_slice(), path)?; + let (profile, repo_local_root) = + crate::paths::path_selection_from_entries(entries.as_slice(), path)?; let mut config = Self::default_with_path_selection(resolver, profile, repo_local_root.as_deref())?; let mut path_overrides = MycPathOverrideFlags::default(); @@ -1372,7 +1053,7 @@ impl MycConfig { line_number, )?; } - config.apply_path_defaults(resolver, &path_overrides)?; + crate::paths::apply_path_defaults(&mut config, resolver, &path_overrides)?; config.validate()?; Ok(config) } @@ -1473,26 +1154,6 @@ fn parse_env_value(value: &str, path: &Path, line_number: usize) -> Result<Strin Ok(value.to_owned()) } -fn path_selection_from_entries( - entries: &[(String, String, usize)], - path: &Path, -) -> Result<(MycPathProfile, Option<PathBuf>), MycError> { - let mut profile = MycPathProfile::InteractiveUser; - let mut repo_local_root = None; - for (key, value, line_number) in entries { - match key.as_str() { - "MYC_PATHS_PROFILE" => { - profile = parse_path_profile_env(key, value, path, *line_number)?; - } - "MYC_PATHS_REPO_LOCAL_ROOT" => { - repo_local_root = parse_optional_path_env(value); - } - _ => {} - } - } - Ok((profile, repo_local_root)) -} - fn apply_env_entry( config: &mut MycConfig, path_overrides: &mut MycPathOverrideFlags, @@ -1516,7 +1177,8 @@ fn apply_env_entry( parse_u64_env(key, value, path, line_number)?; } "MYC_PATHS_PROFILE" => { - config.paths.profile = parse_path_profile_env(key, value, path, line_number)?; + config.paths.profile = + crate::paths::parse_path_profile_env(key, value, path, line_number)?; } "MYC_PATHS_REPO_LOCAL_ROOT" => { config.paths.repo_local_root = parse_optional_path_env(value); @@ -1821,24 +1483,6 @@ fn parse_identity_backend_env( } } -fn parse_path_profile_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<MycPathProfile, MycError> { - match value { - "interactive_user" => Ok(MycPathProfile::InteractiveUser), - "service_host" => Ok(MycPathProfile::ServiceHost), - "repo_local" => Ok(MycPathProfile::RepoLocal), - _ => Err(config_parse_error( - path, - line_number, - format!("{key} must be `interactive_user`, `service_host`, or `repo_local`"), - )), - } -} - fn parse_optional_identity_backend_env( key: &str, value: &str, @@ -1987,7 +1631,7 @@ fn normalize_policy_client_pubkeys(values: &[String]) -> Result<BTreeSet<String> .collect() } -fn parse_optional_path_env(value: &str) -> Option<PathBuf> { +pub(crate) fn parse_optional_path_env(value: &str) -> Option<PathBuf> { parse_optional_string_env(value).map(PathBuf::from) } @@ -2000,7 +1644,11 @@ fn parse_string_list_env(value: &str) -> Vec<String> { .collect() } -fn config_parse_error(path: &Path, line_number: usize, message: impl Into<String>) -> MycError { +pub(crate) fn config_parse_error( + path: &Path, + line_number: usize, + message: impl Into<String>, +) -> MycError { MycError::ConfigParse { path: path.to_path_buf(), line_number, @@ -3317,9 +2965,6 @@ MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite contract.runtime_specific_custody_modes, MycConfig::runtime_specific_custody_modes() ); - assert_eq!( - contract.host_vault_policy, - MycConfig::host_vault_policy() - ); + assert_eq!(contract.host_vault_policy, MycConfig::host_vault_policy()); } } diff --git a/src/custody.rs b/src/custody.rs @@ -1262,10 +1262,8 @@ impl MycIdentityProvider { selected_account_label: None, selected_account_state: None, default_shared_secret_backend: MycConfig::default_shared_secret_backend(), - allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends( - ), - runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes( - ), + allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), + runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), host_vault_policy: MycConfig::host_vault_policy(), identity_id: None, public_key_hex: None, diff --git a/src/lib.rs b/src/lib.rs @@ -14,6 +14,7 @@ pub mod logging; pub mod operability; pub mod outbox; mod outbox_sqlite; +mod paths; pub mod persistence; pub mod policy; pub mod transport; @@ -30,8 +31,8 @@ pub use config::{ DEFAULT_ENV_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycCustodyConfig, MycDiscoveryConfig, MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig, MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, - MycPolicyConfig, MycRuntimeAuditBackend, MycServiceConfig, MycSignerStateBackend, - MycRuntimeContractOutput, MycTransportConfig, MycTransportDeliveryPolicy, + MycPolicyConfig, MycRuntimeAuditBackend, MycRuntimeContractOutput, MycServiceConfig, + MycSignerStateBackend, MycTransportConfig, MycTransportDeliveryPolicy, }; pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; pub use custody::{ diff --git a/src/paths.rs b/src/paths.rs @@ -0,0 +1,385 @@ +use std::path::{Path, PathBuf}; + +use radroots_runtime_paths::{ + RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + config::{ + MycConfig, MycIdentityBackend, MycIdentitySourceSpec, config_parse_error, + parse_optional_path_env, + }, + error::MycError, +}; + +pub const DEFAULT_ENV_PATH: &str = "config.env"; +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"; +const MYC_PATHS_PROFILE_ENV: &str = "MYC_PATHS_PROFILE"; +const MYC_PATHS_REPO_LOCAL_ROOT_ENV: &str = "MYC_PATHS_REPO_LOCAL_ROOT"; + +#[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 config_env_path: 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, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MycPathProfile { + InteractiveUser, + ServiceHost, + RepoLocal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MycResolvedRuntimePaths { + config_env_path: PathBuf, + 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 Default for MycPathProfile { + fn default() -> Self { + Self::InteractiveUser + } +} + +impl MycPathProfile { + pub fn as_str(self) -> &'static str { + match self { + Self::InteractiveUser => "interactive_user", + Self::ServiceHost => "service_host", + Self::RepoLocal => "repo_local", + } + } + + fn into_radroots_profile(self) -> RadrootsPathProfile { + match self { + Self::InteractiveUser => RadrootsPathProfile::InteractiveUser, + Self::ServiceHost => RadrootsPathProfile::ServiceHost, + Self::RepoLocal => RadrootsPathProfile::RepoLocal, + } + } +} + +impl MycResolvedRuntimePaths { + fn resolve( + resolver: &RadrootsPathResolver, + profile: MycPathProfile, + repo_local_root: Option<&Path>, + ) -> Result<Self, MycError> { + let overrides = match profile { + MycPathProfile::InteractiveUser | MycPathProfile::ServiceHost => { + RadrootsPathOverrides::default() + } + MycPathProfile::RepoLocal => { + let repo_local_root = repo_local_root.ok_or_else(|| { + MycError::InvalidConfig( + "paths.repo_local_root must be set when paths.profile is `repo_local`" + .to_owned(), + ) + })?; + RadrootsPathOverrides::repo_local(repo_local_root) + } + }; + let namespace = RadrootsRuntimeNamespace::service("myc") + .map_err(|error| MycError::InvalidConfig(format!("resolve myc namespace: {error}")))?; + let namespaced = resolver + .resolve(profile.into_radroots_profile(), &overrides) + .map_err(|error| { + MycError::InvalidConfig(format!("resolve myc runtime paths: {error}")) + })? + .namespaced(&namespace); + let custody_dir = namespaced.data.join(DEFAULT_CUSTODY_DIR_NAME); + Ok(Self { + config_env_path: namespaced.config.join(DEFAULT_ENV_PATH), + 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), + }) + } +} + +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), + config_env_path: resolved.config_env_path, + 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, + }) + } + + pub fn signer_identity_source(&self) -> MycIdentitySourceSpec { + MycIdentitySourceSpec { + backend: self.signer_identity_backend, + path: match self.signer_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand => Some(self.signer_identity_path.clone()), + MycIdentityBackend::HostVault => None, + }, + keyring_account_id: match self.signer_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault => self.signer_identity_keyring_account_id.clone(), + }, + keyring_service_name: match self.signer_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { + Some(self.signer_identity_keyring_service_name.clone()) + } + }, + profile_path: match self.signer_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault => self.signer_identity_profile_path.clone(), + }, + } + } + + pub fn user_identity_source(&self) -> MycIdentitySourceSpec { + MycIdentitySourceSpec { + backend: self.user_identity_backend, + path: match self.user_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand => Some(self.user_identity_path.clone()), + MycIdentityBackend::HostVault => None, + }, + keyring_account_id: match self.user_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault => self.user_identity_keyring_account_id.clone(), + }, + keyring_service_name: match self.user_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { + Some(self.user_identity_keyring_service_name.clone()) + } + }, + profile_path: match self.user_identity_backend { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault => self.user_identity_profile_path.clone(), + }, + } + } +} + +pub(crate) fn process_path_selection() -> Result<(MycPathProfile, Option<PathBuf>), MycError> { + let profile = match std::env::var(MYC_PATHS_PROFILE_ENV) { + Ok(value) => parse_path_profile_env( + MYC_PATHS_PROFILE_ENV, + value.as_str(), + Path::new("<process-env>"), + 0, + )?, + Err(std::env::VarError::NotPresent) => MycPathProfile::InteractiveUser, + Err(std::env::VarError::NotUnicode(_)) => { + return Err(MycError::InvalidConfig( + "MYC_PATHS_PROFILE must be valid utf-8 when set".to_owned(), + )); + } + }; + let repo_local_root = std::env::var_os(MYC_PATHS_REPO_LOCAL_ROOT_ENV).map(PathBuf::from); + Ok((profile, repo_local_root)) +} + +pub(crate) fn default_env_path_with_path_selection( + resolver: &RadrootsPathResolver, + profile: MycPathProfile, + repo_local_root: Option<&Path>, +) -> Result<PathBuf, MycError> { + Ok(MycResolvedRuntimePaths::resolve(resolver, profile, repo_local_root)?.config_env_path) +} + +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.config_env_path = resolved.config_env_path; + 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(()) +} + +pub(crate) fn path_selection_from_entries( + entries: &[(String, String, usize)], + path: &Path, +) -> Result<(MycPathProfile, Option<PathBuf>), MycError> { + let mut profile = MycPathProfile::InteractiveUser; + let mut repo_local_root = None; + for (key, value, line_number) in entries { + match key.as_str() { + MYC_PATHS_PROFILE_ENV => { + profile = parse_path_profile_env(key, value, path, *line_number)?; + } + MYC_PATHS_REPO_LOCAL_ROOT_ENV => { + repo_local_root = parse_optional_path_env(value); + } + _ => {} + } + } + Ok((profile, repo_local_root)) +} + +pub(crate) fn parse_path_profile_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<MycPathProfile, MycError> { + match value { + "interactive_user" => Ok(MycPathProfile::InteractiveUser), + "service_host" => Ok(MycPathProfile::ServiceHost), + "repo_local" => Ok(MycPathProfile::RepoLocal), + _ => Err(config_parse_error( + path, + line_number, + format!("{key} must be `interactive_user`, `service_host`, or `repo_local`"), + )), + } +} diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs @@ -79,7 +79,10 @@ fn status_summary_command_emits_machine_readable_json() { let value: Value = serde_json::from_slice(&output.stdout).expect("status json"); assert_eq!(value["status"], "unready"); assert_eq!(value["ready"], false); - assert_eq!(value["runtime_contract"]["active_profile"], "interactive_user"); + assert_eq!( + value["runtime_contract"]["active_profile"], + "interactive_user" + ); assert_eq!( value["runtime_contract"]["allowed_profiles"], json!(["interactive_user", "service_host", "repo_local"]) @@ -90,7 +93,12 @@ fn status_summary_command_emits_machine_readable_json() { ); assert_eq!( value["runtime_contract"]["allowed_shared_secret_backends"], - json!(["encrypted_file", "host_vault", "external_command", "plaintext_file"]) + json!([ + "encrypted_file", + "host_vault", + "external_command", + "plaintext_file" + ]) ); assert_eq!( value["runtime_contract"]["runtime_specific_custody_modes"], @@ -198,7 +206,12 @@ fn custody_status_command_reports_role_backend_details() { assert_eq!(value["default_shared_secret_backend"], "encrypted_file"); assert_eq!( value["allowed_shared_secret_backends"], - json!(["encrypted_file", "host_vault", "external_command", "plaintext_file"]) + json!([ + "encrypted_file", + "host_vault", + "external_command", + "plaintext_file" + ]) ); assert_eq!( value["runtime_specific_custody_modes"],