myc

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

commit 9e79449c2a4d3559c38c90e172b9ab8e58c8e91b
parent 828d77cd8f54c39b91047247f6880d330a8cc311
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 12:44:16 +0000

myc: remove prototype configuration surfaces

- delete environment configuration, compatibility CLI, and implicit bootstrap helpers
- bind the process binary exclusively to the hardened one-pass parser
- add fail-closed removal and process-boundary regression coverage

Diffstat:
D.env.example | 116-------------------------------------------------------------------------------
M.gitignore | 1-
MREADME | 20+++++++++++---------
Dsrc/bin/myc_repo_local_identity_bootstrap.rs | 74--------------------------------------------------------------------------
Dsrc/cli.rs | 1520-------------------------------------------------------------------------------
Msrc/config.rs | 1597+------------------------------------------------------------------------------
Msrc/error.rs | 12------------
Msrc/lib.rs | 21+++++----------------
Msrc/logging.rs | 30+++++++++---------------------
Msrc/main.rs | 31++++++++++---------------------
Msrc/paths.rs | 114++-----------------------------------------------------------------------------
Msrc/persistence.rs | 4++--
Dtests/discovery_cli.rs | 1497-------------------------------------------------------------------------------
Dtests/logging_run.rs | 112-------------------------------------------------------------------------------
Dtests/operability_cli.rs | 389-------------------------------------------------------------------------------
Dtests/persistence_cli.rs | 513-------------------------------------------------------------------------------
Atests/services_hardening_legacy_removal.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 151 insertions(+), 6005 deletions(-)

diff --git a/.env.example b/.env.example @@ -1,116 +0,0 @@ -MYC_PATHS_PROFILE=service_host -# repo-owned local runs should prefer the root .env.local control plane, which derives -# MYC_PATHS_PROFILE=repo_local and MYC_PATHS_REPO_LOCAL_ROOT automatically -# or pass `--env-file` to point at a specific config artifact -MYC_SERVICE_INSTANCE_NAME=myc -MYC_LOGGING_FILTER=info,myc=info -MYC_LOGGING_STDOUT=true -MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=10 - -# The canonical control plane is profile/root selection: -# MYC_PATHS_PROFILE selects interactive_user, service_host, or repo_local -# MYC_PATHS_REPO_LOCAL_ROOT selects the repo-local root when profile=repo_local -# The leaf path keys below remain supported as config-file compatibility -# overrides for fixture, migration, and break-glass use; do not export them as -# the normal process-env control plane. -# service_host defaults are derived by the shared runtime path resolver. -# leave explicit leaf path variables commented unless this config artifact is -# intentionally overriding a profile-derived location -MYC_IDENTITY_SIGNER_BACKEND=encrypted_file -# shared backends: encrypted_file, host_vault, external_command, plaintext_file -# runtime-specific custody mode: managed_account -# encrypted_file and plaintext_file: identity file path -# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME -# managed_account: account store file path layered over host-vault-backed custody primitives -# external_command: signer helper executable path -# MYC_IDENTITY_SIGNER_PATH= -MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID= -# host_vault and managed_account both require a non-empty keyring service name -MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.signer -MYC_IDENTITY_SIGNER_PROFILE_PATH= -MYC_IDENTITY_USER_BACKEND=encrypted_file -# shared backends: encrypted_file, host_vault, external_command, plaintext_file -# runtime-specific custody mode: managed_account -# encrypted_file and plaintext_file: identity file path -# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME -# managed_account: account store file path layered over host-vault-backed custody primitives -# external_command: signer helper executable path -# MYC_IDENTITY_USER_PATH= -MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID= -MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.user -MYC_IDENTITY_USER_PROFILE_PATH= - -# production path: use sqlite for both backends -# legacy local/dev path: keep json_file + jsonl_file -MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file -MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file - -MYC_AUDIT_DEFAULT_READ_LIMIT=200 -MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=262144 -MYC_AUDIT_MAX_ARCHIVED_FILES=8 - -MYC_OBSERVABILITY_ENABLED=false -MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9460 - -MYC_DISCOVERY_ENABLED=true -MYC_DISCOVERY_DOMAIN=myc.radroots.org -MYC_DISCOVERY_HANDLER_IDENTIFIER=myc -MYC_IDENTITY_DISCOVERY_APP_BACKEND= -# shared backends: encrypted_file, host_vault, external_command, plaintext_file -# runtime-specific custody mode: managed_account -# encrypted_file and plaintext_file: identity file path -# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME -# managed_account: account store file path layered over host-vault-backed custody primitives -# external_command: signer helper executable path -# MYC_IDENTITY_DISCOVERY_APP_PATH= -MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID= -MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.discovery -MYC_IDENTITY_DISCOVERY_APP_PROFILE_PATH= -MYC_DISCOVERY_PUBLIC_RELAY_URLS=ws://127.0.0.1:8080 -MYC_DISCOVERY_PUBLISH_RELAY_URLS=ws://127.0.0.1:8080 -MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://myc.radroots.org/connect?uri=<nostrconnect> -# MYC_DISCOVERY_NIP05_OUTPUT_PATH= -MYC_DISCOVERY_METADATA_NAME=myc -MYC_DISCOVERY_METADATA_DISPLAY_NAME=Radroots Signer -MYC_DISCOVERY_METADATA_ABOUT=Radroots NIP-46 signer -MYC_DISCOVERY_METADATA_WEBSITE=https://radroots.org -MYC_DISCOVERY_METADATA_PICTURE= - -MYC_POLICY_CONNECTION_APPROVAL=explicit_user -# Client-supplied connect metadata is display-only and never changes approval, -# authentication, permission grants, or signing authorization. -# comma-separated nostr pubkeys that should auto-connect -# MYC_POLICY_TRUSTED_CLIENT_PUBKEYS= -# comma-separated nostr pubkeys that should always be denied -# MYC_POLICY_DENIED_CLIENT_PUBKEYS= -# comma-separated permission ceiling, for example: nip44_encrypt,sign_event:1 -# MYC_POLICY_PERMISSION_CEILING= -# comma-separated sign_event kinds allowed by policy, for example: 1,7 -# MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS= -# set MYC_POLICY_AUTH_URL to enable automatic auth challenge policy for trusted sessions -# MYC_POLICY_AUTH_URL=https://myc.radroots.org/auth/challenge -MYC_POLICY_AUTH_PENDING_TTL_SECS=900 -# set these when automatic auth challenge policy should expire trusted sessions -# MYC_POLICY_AUTHORIZED_TTL_SECS=3600 -# MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 -# optional per-client connect attempt throttle -# MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60 -# MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5 -# optional per-client automatic auth challenge issuance throttle -# MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120 -# MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3 - -MYC_TRANSPORT_ENABLED=true -MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 -# Ordered comma-separated NIP-46 relay URLs. Relay hosting and lifecycle remain -# external to Myc; production operators should use their approved secure relay -# transport rather than copying the loopback example. -MYC_TRANSPORT_RELAY_URLS=ws://127.0.0.1:8080 -# Logout acknowledgement delivery follows this policy and is finalized before -# session revocation; failed acknowledgement delivery is retried on restart. -MYC_TRANSPORT_DELIVERY_POLICY=any -# set MYC_TRANSPORT_DELIVERY_QUORUM when MYC_TRANSPORT_DELIVERY_POLICY=quorum -# MYC_TRANSPORT_DELIVERY_QUORUM=2 -MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS=1 -MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS=250 -MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=2000 diff --git a/.gitignore b/.gitignore @@ -3,7 +3,6 @@ # Local environment files .env .env.* -!.env.example # OS and editor files .DS_Store diff --git a/README b/README @@ -20,8 +20,9 @@ requires explicit `--profile <service-host|interactive|repo-local>` and `--config` path. Its exact command inventory is `run`; `config init|validate|show|schema`; `state init|status|backup|restore|verify|migrate`; `identity init|status|rekey|replace|export-public`; `status`; and `doctor`. -This parser is a pure admission boundary in the current checkpoint; Step 118 -owns removal of the prototype parser and runtime dispatch migration. +The process binary uses only this parser. Hardened command execution remains +fail-closed until its ordered runtime-dispatch steps are complete; no prototype +command is used as a fallback. `parse_myc_config_v1` caps original bytes before decoding, checks the schema header before closed contract admission, rejects duplicate, null, unknown, and @@ -31,14 +32,15 @@ leaf records whether it came from the document or one of the exact governed default authorities. Ordinary errors and `Debug` output contain no source text, paths, credentials, relay URLs, or identity values. -The current runtime loader remains transitional until its ordered replacement -steps are complete. Existing environment variables and `.env.example` are -prototype evidence, not authority to change or weaken the v1 contract. +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. ## NIP-46 runtime contract -Myc listens for encrypted kind-24133 requests on the ordered relay set in -`MYC_TRANSPORT_RELAY_URLS`. The signer transport identity authors and encrypts +Myc listens for encrypted kind-24133 requests on the exact configured relay +inventory. The signer transport identity authors and encrypts protocol responses; the separate user identity is returned by `get_public_key` and signs user events. Client-supplied connect metadata is a bounded, display-only hint and never changes approval, authentication, or @@ -46,8 +48,8 @@ permissions. The public approval default is `explicit_user`. Trusted and denied clients, permission ceilings, allowed signing kinds, auth challenges, relay switching, -and delivery policy are configured explicitly through the variables documented -in `.env.example`. A successful `logout` publishes its acknowledgement before +and delivery policy are represented only by the strict v1 TOML contract. A +successful `logout` publishes its acknowledgement before revoking the session. Failed acknowledgement delivery remains recoverable on restart, while requests from a revoked session remain unauthorized until a new connect is approved. diff --git a/src/bin/myc_repo_local_identity_bootstrap.rs b/src/bin/myc_repo_local_identity_bootstrap.rs @@ -1,74 +0,0 @@ -#![forbid(unsafe_code)] - -use std::env; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; - -use myc::host_identity::RadrootsIdentity; -use myc::identity_files::{load_encrypted_identity, store_encrypted_identity}; - -fn main() -> ExitCode { - match run() { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("{err}"); - ExitCode::from(1) - } - } -} - -fn run() -> Result<(), String> { - let runtime_root = runtime_root_from_args()?; - let resolved = resolve_runtime_paths(&runtime_root)?; - - ensure_identity(&resolved.signer_identity_path)?; - ensure_identity(&resolved.user_identity_path)?; - - println!( - "ok bootstrap-myc-repo-local-identities {}", - runtime_root.display() - ); - Ok(()) -} - -fn runtime_root_from_args() -> Result<PathBuf, String> { - let mut args = env::args_os(); - let _ = args.next(); - let Some(runtime_root) = args.next() else { - return Err("usage: myc_repo_local_identity_bootstrap <runtime-root>".to_owned()); - }; - if args.next().is_some() { - return Err("usage: myc_repo_local_identity_bootstrap <runtime-root>".to_owned()); - } - Ok(PathBuf::from(runtime_root)) -} - -struct MycRuntimePaths { - signer_identity_path: PathBuf, - user_identity_path: PathBuf, -} - -fn resolve_runtime_paths(runtime_root: &Path) -> Result<MycRuntimePaths, String> { - let secrets = runtime_root.join("secrets").join("services").join("myc"); - Ok(MycRuntimePaths { - signer_identity_path: secrets.join("signer-identity.json"), - user_identity_path: secrets.join("user-identity.json"), - }) -} - -fn ensure_identity(path: &Path) -> Result<(), String> { - if path.is_file() { - load_encrypted_identity(path) - .map_err(|err| format!("load encrypted identity {}: {err}", path.display()))?; - return Ok(()); - } - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|err| format!("create identity dir {}: {err}", parent.display()))?; - } - let identity = RadrootsIdentity::generate(); - store_encrypted_identity(path, &identity) - .map_err(|err| format!("store encrypted identity {}: {err}", path.display()))?; - Ok(()) -} diff --git a/src/cli.rs b/src/cli.rs @@ -1,1520 +0,0 @@ -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use crate::signer::prelude::{ - RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionId, - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord, -}; -use clap::{Args, Parser, Subcommand, ValueEnum}; -use radroots_nostr_connect::permission::Permissions; -use serde::Serialize; -use zeroize::Zeroizing; - -use crate::app::MycRuntime; -use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; -use crate::config::{MycConfig, MycTransportDeliveryPolicy}; -use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values}; -use crate::discovery::{ - MycDiscoveryContext, MycDiscoveryRepairSummary, diff_live_nip89, fetch_live_nip89, - publish_nip89_event, refresh_nip89, verify_bundle, -}; -use crate::error::MycError; -use crate::logging; -use crate::operability::{ - MycAuditDecisionCounts, MycOperationOutcomeCounts, MycStatusFullOutput, MycStatusSignerOutput, - MycStatusSummaryOutput, collect_metrics, collect_status_full, collect_status_signer, - collect_status_summary, increment_outcome_counts, is_aggregate_publish_operation, - operation_kind_label, render_metrics_text, -}; -use crate::persistence::{ - MycPersistenceImportSelection, backup_persistence, import_json_to_sqlite, restore_backup, - verify_restored_state, -}; - -#[derive(Debug, Parser)] -#[command(name = "myc")] -#[command(about = "Mycorrhiza NIP-46 signer service")] -pub struct MycCli { - #[arg(long = "env-file", global = true)] - env_file: Option<PathBuf>, - #[command(subcommand)] - command: Option<MycCommand>, -} - -#[derive(Debug, Subcommand)] -pub enum MycCommand { - Run, - Status { - #[arg(long, value_enum, default_value_t = MycStatusView::Summary)] - view: MycStatusView, - }, - Metrics { - #[arg(long, value_enum, default_value_t = MycMetricsFormat::Prometheus)] - format: MycMetricsFormat, - }, - Persistence { - #[command(subcommand)] - command: MycPersistenceCommand, - }, - Custody { - #[command(subcommand)] - command: MycCustodyCommand, - }, - Connections { - #[command(subcommand)] - command: MycConnectionsCommand, - }, - Audit { - #[command(subcommand)] - command: MycAuditCommand, - }, - Auth { - #[command(subcommand)] - command: MycAuthCommand, - }, - Connect { - #[command(subcommand)] - command: MycConnectCommand, - }, - Discovery { - #[command(subcommand)] - command: MycDiscoveryCommand, - }, -} - -#[derive(Debug, Subcommand)] -pub enum MycConnectionsCommand { - List, - Approve(MycConnectionApprovalArgs), - Reject(MycConnectionReasonArgs), - Revoke(MycConnectionReasonArgs), -} - -#[derive(Debug, Subcommand)] -pub enum MycPersistenceCommand { - Backup { - #[arg(long)] - out: PathBuf, - }, - Restore { - #[arg(long)] - from: PathBuf, - }, - ImportJsonToSqlite { - #[arg(long)] - signer_state: bool, - #[arg(long)] - runtime_audit: bool, - }, - VerifyRestore, -} - -#[derive(Debug, Subcommand)] -pub enum MycCustodyCommand { - Status { - #[arg(long, value_enum)] - role: MycCustodyRole, - }, - List { - #[arg(long, value_enum)] - role: MycCustodyRole, - }, - Generate { - #[arg(long, value_enum)] - role: MycCustodyRole, - #[arg(long)] - label: Option<String>, - #[arg(long)] - select: bool, - }, - ImportFile { - #[arg(long, value_enum)] - role: MycCustodyRole, - #[arg(long)] - path: PathBuf, - #[arg(long)] - label: Option<String>, - #[arg(long)] - select: bool, - }, - ExportNip49 { - #[arg(long, value_enum)] - role: MycCustodyRole, - #[arg(long)] - out: PathBuf, - #[arg(long)] - password_env: String, - }, - ImportNip49 { - #[arg(long, value_enum)] - role: MycCustodyRole, - #[arg(long)] - path: PathBuf, - #[arg(long)] - password_env: String, - #[arg(long)] - label: Option<String>, - }, - Rotate { - #[arg(long, value_enum)] - role: MycCustodyRole, - }, - Select { - #[arg(long, value_enum)] - role: MycCustodyRole, - #[arg(long)] - account_id: String, - }, - Remove { - #[arg(long, value_enum)] - role: MycCustodyRole, - #[arg(long)] - account_id: String, - }, -} - -#[derive(Debug, Subcommand)] -pub enum MycAuditCommand { - List { - #[arg(long)] - connection_id: Option<String>, - #[arg(long)] - attempt_id: Option<String>, - #[arg(long, value_enum, default_value_t = MycAuditScope::All)] - scope: MycAuditScope, - #[arg(long)] - limit: Option<usize>, - }, - Summary { - #[arg(long)] - connection_id: Option<String>, - #[arg(long)] - attempt_id: Option<String>, - #[arg(long, value_enum, default_value_t = MycAuditScope::All)] - scope: MycAuditScope, - #[arg(long)] - limit: Option<usize>, - }, - LatestDiscoveryRepair { - #[arg(long, value_enum, default_value_t = MycDiscoveryRepairAttemptView::Summary)] - view: MycDiscoveryRepairAttemptView, - }, - DiscoveryRepairAttempt { - #[arg(long)] - attempt_id: String, - #[arg(long, value_enum, default_value_t = MycDiscoveryRepairAttemptView::Summary)] - view: MycDiscoveryRepairAttemptView, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum MycAuditScope { - All, - Request, - Operation, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum MycDiscoveryRepairAttemptView { - Summary, - Records, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum MycStatusView { - Signer, - Summary, - Full, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum MycMetricsFormat { - Json, - Prometheus, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum MycCustodyRole { - Signer, - User, - DiscoveryApp, -} - -#[derive(Debug, Subcommand)] -pub enum MycAuthCommand { - Require { - #[arg(long)] - connection_id: String, - #[arg(long)] - url: String, - }, - Authorize { - #[arg(long)] - connection_id: String, - }, -} - -#[derive(Debug, Subcommand)] -pub enum MycConnectCommand { - Accept { - #[arg(long)] - uri: String, - }, -} - -#[derive(Debug, Subcommand)] -pub enum MycDiscoveryCommand { - RenderNip05 { - #[arg(long)] - out: Option<PathBuf>, - #[arg(long)] - stdout: bool, - }, - RenderNip89, - PublishNip89, - ExportBundle { - #[arg(long)] - out: PathBuf, - }, - VerifyBundle { - #[arg(long)] - dir: PathBuf, - }, - InspectLiveNip89, - DiffLiveNip89, - RefreshNip89 { - #[arg(long)] - force: bool, - }, -} - -#[derive(Debug, Args)] -pub struct MycConnectionApprovalArgs { - #[arg(long)] - connection_id: String, - #[arg(long = "grant")] - grants: Vec<String>, -} - -#[derive(Debug, Args)] -pub struct MycConnectionReasonArgs { - #[arg(long)] - connection_id: String, - #[arg(long)] - reason: Option<String>, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -pub struct MycAuditListOutput { - pub signer_request_audit: Vec<RadrootsNostrSignerRequestAuditRecord>, - pub runtime_operation_audit: Vec<MycOperationAuditRecord>, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -pub struct MycAuditSummaryOutput { - pub record_limit: usize, - pub signer_request_total: usize, - pub signer_request_decisions: MycAuditDecisionCounts, - pub runtime_operation_total: usize, - pub runtime_operation_outcomes: MycOperationOutcomeCounts, - pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>, - pub runtime_aggregate_publish_rejection_count: usize, - pub runtime_repair_success_count: usize, - pub runtime_repair_rejection_count: usize, - pub runtime_unavailable_count: usize, - pub runtime_replay_restore_count: usize, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -pub struct MycDiscoveryRepairAttemptRecordsOutput { - pub attempt_id: String, - pub runtime_operation_audit: Vec<MycOperationAuditRecord>, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -pub struct MycDiscoveryRepairAttemptSummaryOutput { - pub attempt_id: String, - pub record_count: usize, - pub started_at_unix: u64, - pub finished_at_unix: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub compare_outcome: Option<MycOperationAuditOutcome>, - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_outcome: Option<MycOperationAuditOutcome>, - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregate_publish_outcome: Option<MycOperationAuditOutcome>, - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregate_publish_relay_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregate_publish_acknowledged_relay_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregate_publish_relay_outcome_summary: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregate_publish_delivery_policy: Option<MycTransportDeliveryPolicy>, - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregate_publish_required_acknowledged_relay_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub aggregate_publish_attempt_count: Option<usize>, - pub repair_summary: MycDiscoveryRepairSummary, - pub planned_repair_relays: Vec<String>, - pub blocked_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub blocked_reason: Option<String>, - pub failed_relays: Vec<String>, - pub remaining_repair_relays: Vec<String>, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -#[serde(untagged)] -pub enum MycDiscoveryRepairAttemptOutput { - Summary(Box<MycDiscoveryRepairAttemptSummaryOutput>), - Records(MycDiscoveryRepairAttemptRecordsOutput), -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -#[serde(untagged)] -pub enum MycStatusOutput { - Signer(Box<MycStatusSignerOutput>), - Summary(Box<MycStatusSummaryOutput>), - Full(Box<MycStatusFullOutput>), -} - -pub async fn run_from_env() -> Result<(), MycError> { - let cli = MycCli::parse(); - let config = load_config(cli.env_file.as_deref())?; - - match cli.command.unwrap_or(MycCommand::Run) { - MycCommand::Run => { - logging::init_logging(&config.logging)?; - MycRuntime::bootstrap(config)?.run().await - } - MycCommand::Status { view } => { - let runtime = MycRuntime::bootstrap(config)?; - let output = match view { - MycStatusView::Signer => { - MycStatusOutput::Signer(Box::new(collect_status_signer(&runtime)?)) - } - MycStatusView::Summary => { - MycStatusOutput::Summary(Box::new(collect_status_summary(&runtime).await?)) - } - MycStatusView::Full => { - MycStatusOutput::Full(Box::new(collect_status_full(&runtime).await?)) - } - }; - print_json(&output) - } - MycCommand::Metrics { format } => { - let runtime = MycRuntime::bootstrap(config)?; - let output = collect_metrics(&runtime)?; - match format { - MycMetricsFormat::Json => print_json(&output), - MycMetricsFormat::Prometheus => { - print_text(&render_metrics_text(&output)); - Ok(()) - } - } - } - MycCommand::Persistence { command } => match command { - MycPersistenceCommand::Backup { out } => { - let output = backup_persistence(&config, out)?; - print_json(&output) - } - MycPersistenceCommand::Restore { from } => { - let output = restore_backup(&config, from)?; - print_json(&output) - } - MycPersistenceCommand::ImportJsonToSqlite { - signer_state, - runtime_audit, - } => { - let output = import_json_to_sqlite( - &config, - MycPersistenceImportSelection::new(signer_state, runtime_audit), - )?; - print_json(&output) - } - MycPersistenceCommand::VerifyRestore => { - let output = verify_restored_state(&config)?; - print_json(&output) - } - }, - MycCommand::Custody { command } => { - let provider = custody_provider_for_command(&config, &command)?; - match command { - MycCustodyCommand::Status { .. } => print_json(&provider.status_output()), - MycCustodyCommand::List { .. } => print_json(&provider.list_managed_accounts()?), - MycCustodyCommand::Generate { label, select, .. } => { - let output = provider.generate_managed_account(label, select)?; - print_json(&output) - } - MycCustodyCommand::ImportFile { - path, - label, - select, - .. - } => { - let output = provider.import_managed_account_file(path, label, select)?; - print_json(&output) - } - MycCustodyCommand::ExportNip49 { - out, password_env, .. - } => { - let password = read_secret_env(password_env.as_str(), "custody export-nip49")?; - let output = provider.export_nip49(out, password.as_str())?; - print_json(&output) - } - MycCustodyCommand::ImportNip49 { - path, - password_env, - label, - .. - } => { - let password = read_secret_env(password_env.as_str(), "custody import-nip49")?; - let output = provider.import_nip49(path, password.as_str(), label)?; - print_json(&output) - } - MycCustodyCommand::Rotate { .. } => { - let output = provider.rotate_secret_storage()?; - print_json(&output) - } - MycCustodyCommand::Select { account_id, .. } => { - let output = provider.select_managed_account(account_id.as_str())?; - print_json(&output) - } - MycCustodyCommand::Remove { account_id, .. } => { - let output = provider.remove_managed_account(account_id.as_str())?; - print_json(&output) - } - } - } - MycCommand::Connections { command } => { - let runtime = MycRuntime::bootstrap(config)?; - let backend = runtime.signer_backend(); - match command { - MycConnectionsCommand::List => print_json(&backend.list_connections()?), - MycConnectionsCommand::Approve(args) => { - let connection_id = parse_connection_id(&args.connection_id)?; - let granted_permissions = granted_permissions_for_approval( - runtime.signer_context().policy(), - &backend.list_connections()?, - &connection_id, - &args.grants, - )?; - let connection = - backend.approve_connection(&connection_id, granted_permissions)?; - print_json(&connection) - } - MycConnectionsCommand::Reject(args) => { - let connection_id = parse_connection_id(&args.connection_id)?; - let connection = backend.reject_connection(&connection_id, args.reason)?; - print_json(&connection) - } - MycConnectionsCommand::Revoke(args) => { - let connection_id = parse_connection_id(&args.connection_id)?; - let connection = backend.revoke_connection(&connection_id, args.reason)?; - print_json(&connection) - } - } - } - MycCommand::Audit { command } => { - let runtime = MycRuntime::bootstrap(config)?; - let manager = runtime.signer_manager()?; - match command { - MycAuditCommand::List { - connection_id, - attempt_id, - scope, - limit, - } => { - let output = load_audit_output( - &runtime, - &manager, - connection_id.as_deref(), - attempt_id.as_deref(), - scope, - limit, - )?; - print_json(&output) - } - MycAuditCommand::Summary { - connection_id, - attempt_id, - scope, - limit, - } => { - let output = summarize_audit_output( - &runtime, - &manager, - connection_id.as_deref(), - attempt_id.as_deref(), - scope, - limit, - )?; - print_json(&output) - } - MycAuditCommand::LatestDiscoveryRepair { view } => { - let output = load_latest_discovery_repair_attempt_output(&runtime, view)?; - print_json(&output) - } - MycAuditCommand::DiscoveryRepairAttempt { attempt_id, view } => { - let output = - load_discovery_repair_attempt_output(&runtime, attempt_id.as_str(), view)?; - print_json(&output) - } - } - } - MycCommand::Auth { command } => { - let runtime = MycRuntime::bootstrap(config)?; - let backend = runtime.signer_backend(); - match command { - MycAuthCommand::Require { connection_id, url } => { - let connection_id = parse_connection_id(&connection_id)?; - let connection = backend.require_auth_challenge(&connection_id, &url)?; - print_json(&connection) - } - MycAuthCommand::Authorize { connection_id } => { - let connection_id = parse_connection_id(&connection_id)?; - let replayed = authorize_auth_challenge(&runtime, &connection_id).await?; - print_json(&replayed) - } - } - } - MycCommand::Connect { command } => { - let runtime = MycRuntime::bootstrap(config)?; - match command { - MycConnectCommand::Accept { uri } => { - let accepted = accept_client_uri(&runtime, &uri).await?; - print_json(&accepted) - } - } - } - MycCommand::Discovery { command } => match command { - MycDiscoveryCommand::VerifyBundle { dir } => { - let output = verify_bundle(dir)?; - print_json(&output) - } - MycDiscoveryCommand::InspectLiveNip89 => { - let runtime = MycRuntime::bootstrap(config.clone())?; - let output = fetch_live_nip89(&runtime).await?; - print_json(&output) - } - MycDiscoveryCommand::DiffLiveNip89 => { - let runtime = MycRuntime::bootstrap(config.clone())?; - let output = diff_live_nip89(&runtime).await?; - print_json(&output) - } - MycDiscoveryCommand::RefreshNip89 { force } => { - let runtime = MycRuntime::bootstrap(config.clone())?; - let output = refresh_nip89(&runtime, force).await?; - print_json(&output) - } - MycDiscoveryCommand::RenderNip05 { out, stdout } => { - let runtime = MycRuntime::bootstrap(config.clone())?; - if stdout && out.is_some() { - return Err(MycError::InvalidOperation( - "discovery render-nip05 cannot use --stdout and --out together".to_owned(), - )); - } - let context = MycDiscoveryContext::from_runtime(&runtime)?; - if stdout || (out.is_none() && context.nip05_output_path().is_none()) { - println!("{}", context.render_nip05_json_pretty()?); - Ok(()) - } else { - let output = context.write_nip05_document( - out.as_deref().or(context.nip05_output_path()).ok_or_else(|| { - MycError::InvalidOperation( - "discovery render-nip05 requires --out or discovery.nip05_output_path" - .to_owned(), - ) - })?, - )?; - print_json(&output) - } - } - MycDiscoveryCommand::RenderNip89 => { - let runtime = MycRuntime::bootstrap(config.clone())?; - let output = MycDiscoveryContext::from_runtime(&runtime)?.render_nip89_output()?; - print_json(&output) - } - MycDiscoveryCommand::PublishNip89 => { - let runtime = MycRuntime::bootstrap(config.clone())?; - let output = publish_nip89_event(&runtime).await?; - print_json(&output) - } - MycDiscoveryCommand::ExportBundle { out } => { - let runtime = MycRuntime::bootstrap(config)?; - let output = MycDiscoveryContext::from_runtime(&runtime)?.write_bundle(out)?; - print_json(&output) - } - }, - } -} - -fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> { - match path { - Some(path) => MycConfig::load_from_env_path(path), - None => MycConfig::load_from_default_env_path(), - } -} - -fn custody_provider_for_command( - config: &MycConfig, - command: &MycCustodyCommand, -) -> Result<crate::custody::MycIdentityProvider, MycError> { - let role = match command { - MycCustodyCommand::Status { role } - | MycCustodyCommand::List { role } - | MycCustodyCommand::Generate { role, .. } - | MycCustodyCommand::ImportFile { role, .. } - | MycCustodyCommand::ExportNip49 { role, .. } - | MycCustodyCommand::ImportNip49 { role, .. } - | MycCustodyCommand::Rotate { role } - | MycCustodyCommand::Select { role, .. } - | MycCustodyCommand::Remove { role, .. } => *role, - }; - - custody_provider_for_role(config, role) -} - -fn custody_provider_for_role( - config: &MycConfig, - role: MycCustodyRole, -) -> Result<crate::custody::MycIdentityProvider, MycError> { - match role { - MycCustodyRole::Signer => crate::custody::MycIdentityProvider::from_source( - "signer", - config.paths.signer_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - ), - MycCustodyRole::User => crate::custody::MycIdentityProvider::from_source( - "user", - config.paths.user_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - ), - MycCustodyRole::DiscoveryApp => { - let Some(source) = config.discovery.app_identity_source() else { - return Err(MycError::InvalidOperation( - "discovery app identity is not separately configured; it currently reuses the signer identity".to_owned(), - )); - }; - crate::custody::MycIdentityProvider::from_source( - "discovery app", - source, - Duration::from_secs(config.custody.external_command_timeout_secs), - ) - } - } -} - -fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, MycError> { - Ok(RadrootsNostrSignerConnectionId::parse(value)?) -} - -fn granted_permissions_for_approval( - policy: &crate::policy::MycPolicyContext, - connections: &[RadrootsNostrSignerConnectionRecord], - connection_id: &RadrootsNostrSignerConnectionId, - grants: &[String], -) -> Result<Permissions, MycError> { - if !grants.is_empty() { - return policy.validate_operator_grants(parse_permission_values(grants)?); - } - - let connection = connections - .iter() - .find(|connection| &connection.connection_id == connection_id) - .ok_or_else(|| { - MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) - })?; - policy.validate_operator_grants(connection.requested_permissions.clone()) -} - -fn load_audit_output( - runtime: &MycRuntime, - manager: &crate::signer::prelude::RadrootsNostrSignerManager, - connection_id: Option<&str>, - attempt_id: Option<&str>, - scope: MycAuditScope, - limit: Option<usize>, -) -> Result<MycAuditListOutput, MycError> { - if connection_id.is_some() && attempt_id.is_some() { - return Err(MycError::InvalidOperation( - "audit commands cannot filter by both connection_id and attempt_id".to_owned(), - )); - } - if attempt_id.is_some() && scope == MycAuditScope::Request { - return Err(MycError::InvalidOperation( - "audit attempt lookup only supports operation or all scope".to_owned(), - )); - } - - let limit = audit_read_limit(runtime, limit); - let connection_id = connection_id.map(parse_connection_id).transpose()?; - let signer_request_audit = match (scope, connection_id.as_ref()) { - (MycAuditScope::Operation, _) => Vec::new(), - (_, Some(connection_id)) => manager - .audit_records_for_connection(connection_id)? - .into_iter() - .rev() - .take(limit) - .collect::<Vec<_>>() - .into_iter() - .rev() - .collect(), - (_, None) => manager - .list_audit_records()? - .into_iter() - .rev() - .take(limit) - .collect::<Vec<_>>() - .into_iter() - .rev() - .collect(), - }; - let runtime_operation_audit = match (scope, connection_id.as_ref(), attempt_id) { - (MycAuditScope::Request, _, _) => Vec::new(), - (_, Some(connection_id), _) => runtime - .operation_audit_store() - .list_for_connection_with_limit(connection_id, limit)?, - (_, None, Some(attempt_id)) => runtime - .operation_audit_store() - .list_for_attempt_id_with_limit(attempt_id, limit)?, - (_, None, None) => runtime.operation_audit_store().list_with_limit(limit)?, - }; - - Ok(MycAuditListOutput { - signer_request_audit, - runtime_operation_audit, - }) -} - -fn summarize_audit_output( - runtime: &MycRuntime, - manager: &crate::signer::prelude::RadrootsNostrSignerManager, - connection_id: Option<&str>, - attempt_id: Option<&str>, - scope: MycAuditScope, - limit: Option<usize>, -) -> Result<MycAuditSummaryOutput, MycError> { - let record_limit = audit_read_limit(runtime, limit); - let audit = load_audit_output( - runtime, - manager, - connection_id, - attempt_id, - scope, - Some(record_limit), - )?; - let mut signer_request_decisions = MycAuditDecisionCounts::default(); - for record in &audit.signer_request_audit { - match record.decision { - crate::signer::prelude::RadrootsNostrSignerRequestDecision::Allowed => { - signer_request_decisions.allowed += 1; - } - crate::signer::prelude::RadrootsNostrSignerRequestDecision::Denied => { - signer_request_decisions.denied += 1; - } - crate::signer::prelude::RadrootsNostrSignerRequestDecision::Challenged => { - signer_request_decisions.challenged += 1; - } - } - } - - let mut runtime_operation_outcomes = MycOperationOutcomeCounts::default(); - let mut runtime_operation_by_kind = BTreeMap::new(); - let mut runtime_aggregate_publish_rejection_count = 0; - let mut runtime_repair_success_count = 0; - let mut runtime_repair_rejection_count = 0; - let mut runtime_unavailable_count = 0; - let mut runtime_replay_restore_count = 0; - for record in &audit.runtime_operation_audit { - increment_outcome_counts(&mut runtime_operation_outcomes, record.outcome); - let key = operation_kind_label(record.operation); - increment_outcome_counts( - runtime_operation_by_kind.entry(key).or_default(), - record.outcome, - ); - if is_aggregate_publish_operation(record.operation) - && record.outcome == MycOperationAuditOutcome::Rejected - { - runtime_aggregate_publish_rejection_count += 1; - } - if record.operation == MycOperationAuditKind::DiscoveryHandlerRepair { - match record.outcome { - MycOperationAuditOutcome::Succeeded => runtime_repair_success_count += 1, - MycOperationAuditOutcome::Rejected => runtime_repair_rejection_count += 1, - _ => {} - } - } - if record.outcome == MycOperationAuditOutcome::Unavailable { - runtime_unavailable_count += 1; - } - if record.operation == MycOperationAuditKind::AuthReplayRestore - && record.outcome == MycOperationAuditOutcome::Restored - { - runtime_replay_restore_count += 1; - } - } - - Ok(MycAuditSummaryOutput { - record_limit, - signer_request_total: audit.signer_request_audit.len(), - signer_request_decisions, - runtime_operation_total: audit.runtime_operation_audit.len(), - runtime_operation_outcomes, - runtime_operation_by_kind, - runtime_aggregate_publish_rejection_count, - runtime_repair_success_count, - runtime_repair_rejection_count, - runtime_unavailable_count, - runtime_replay_restore_count, - }) -} - -fn load_latest_discovery_repair_attempt_output( - runtime: &MycRuntime, - view: MycDiscoveryRepairAttemptView, -) -> Result<MycDiscoveryRepairAttemptOutput, MycError> { - let attempt_id = runtime - .operation_audit_store() - .latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh)? - .ok_or_else(|| { - MycError::InvalidOperation("no discovery repair attempts have been recorded".to_owned()) - })?; - load_discovery_repair_attempt_output(runtime, attempt_id.as_str(), view) -} - -fn load_discovery_repair_attempt_output( - runtime: &MycRuntime, - attempt_id: &str, - view: MycDiscoveryRepairAttemptView, -) -> Result<MycDiscoveryRepairAttemptOutput, MycError> { - let records = runtime - .operation_audit_store() - .list_for_attempt_id(attempt_id)?; - if records.is_empty() { - return Err(MycError::InvalidOperation(format!( - "discovery repair attempt `{attempt_id}` was not found" - ))); - } - - match view { - MycDiscoveryRepairAttemptView::Summary => { - Ok(MycDiscoveryRepairAttemptOutput::Summary(Box::new( - MycDiscoveryRepairAttemptSummaryOutput::from_records(attempt_id, &records)?, - ))) - } - MycDiscoveryRepairAttemptView::Records => Ok(MycDiscoveryRepairAttemptOutput::Records( - MycDiscoveryRepairAttemptRecordsOutput { - attempt_id: attempt_id.to_owned(), - runtime_operation_audit: records, - }, - )), - } -} - -fn audit_read_limit(runtime: &MycRuntime, limit: Option<usize>) -> usize { - limit.unwrap_or(runtime.operation_audit_store().config().default_read_limit) -} - -impl MycDiscoveryRepairAttemptSummaryOutput { - fn from_records( - attempt_id: &str, - records: &[MycOperationAuditRecord], - ) -> Result<Self, MycError> { - let Some(first_record) = records.first() else { - return Err(MycError::InvalidOperation(format!( - "discovery repair attempt `{attempt_id}` had no records" - ))); - }; - let finished_at_unix = records - .last() - .map(|record| record.recorded_at_unix) - .unwrap_or(first_record.recorded_at_unix); - let compare_outcome = records.iter().find_map(|record| { - (record.operation == MycOperationAuditKind::DiscoveryHandlerCompare) - .then_some(record.outcome) - }); - let refresh_outcome = records.iter().rev().find_map(|record| { - (record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh) - .then_some(record.outcome) - }); - let refresh_record = records - .iter() - .rev() - .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh); - let publish_record = records - .iter() - .rev() - .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerPublish); - - let mut repair_summary = MycDiscoveryRepairSummary::default(); - let mut failed_relays = Vec::new(); - for record in records - .iter() - .filter(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRepair) - { - match record.outcome { - MycOperationAuditOutcome::Succeeded => repair_summary.repaired += 1, - MycOperationAuditOutcome::Rejected => { - repair_summary.failed += 1; - if let Some(relay_url) = record.relay_url.clone() { - failed_relays.push(relay_url); - } - } - MycOperationAuditOutcome::Matched => repair_summary.unchanged += 1, - MycOperationAuditOutcome::Skipped => repair_summary.skipped += 1, - _ => {} - } - } - failed_relays.sort(); - failed_relays.dedup(); - let planned_repair_relays = refresh_record - .map(|record| record.planned_repair_relays.clone()) - .unwrap_or_default(); - let blocked_relays = refresh_record - .map(|record| record.blocked_relays.clone()) - .unwrap_or_default(); - let blocked_reason = refresh_record.and_then(|record| record.blocked_reason.clone()); - let remaining_repair_relays = if !failed_relays.is_empty() { - failed_relays.clone() - } else if matches!( - refresh_outcome, - Some( - MycOperationAuditOutcome::Unavailable - | MycOperationAuditOutcome::Conflicted - | MycOperationAuditOutcome::Rejected - ) - ) { - planned_repair_relays.clone() - } else { - Vec::new() - }; - - Ok(Self { - attempt_id: attempt_id.to_owned(), - record_count: records.len(), - started_at_unix: first_record.recorded_at_unix, - finished_at_unix, - compare_outcome, - refresh_outcome, - aggregate_publish_outcome: publish_record.map(|record| record.outcome), - aggregate_publish_relay_count: publish_record.map(|record| record.relay_count), - aggregate_publish_acknowledged_relay_count: publish_record - .map(|record| record.acknowledged_relay_count), - aggregate_publish_relay_outcome_summary: publish_record - .map(|record| record.relay_outcome_summary.clone()), - aggregate_publish_delivery_policy: publish_record - .and_then(|record| record.delivery_policy), - aggregate_publish_required_acknowledged_relay_count: publish_record - .and_then(|record| record.required_acknowledged_relay_count), - aggregate_publish_attempt_count: publish_record - .and_then(|record| record.publish_attempt_count), - repair_summary, - planned_repair_relays, - blocked_relays, - blocked_reason, - failed_relays: failed_relays.clone(), - remaining_repair_relays, - }) - } -} - -fn print_json<T>(value: &T) -> Result<(), MycError> -where - T: Serialize, -{ - println!("{}", serde_json::to_string_pretty(value)?); - Ok(()) -} - -fn print_text(value: &str) { - println!("{value}"); -} - -fn read_secret_env(name: &str, operation: &str) -> Result<Zeroizing<String>, MycError> { - let value = std::env::var(name).map_err(|_| { - MycError::InvalidOperation(format!( - "{operation} requires environment variable `{name}` to be set" - )) - })?; - if value.is_empty() { - return Err(MycError::InvalidOperation(format!( - "{operation} requires environment variable `{name}` to be non-empty" - ))); - } - Ok(Zeroizing::new(value)) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::host_identity::RadrootsIdentity; - use crate::signer::prelude::RadrootsNostrSignerConnectionDraft; - use clap::Parser; - use nostr::Timestamp; - use radroots_nostr_connect::Request; - use serde_json::json; - - use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; - use crate::config::MycConfig; - - use super::{ - MycAuditScope, MycCli, MycCommand, MycCustodyCommand, MycCustodyRole, MycStatusView, - granted_permissions_for_approval, load_audit_output, summarize_audit_output, - }; - use crate::app::MycRuntime; - - 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"); - } - - fn runtime() -> MycRuntime { - runtime_with_config(|_| {}) - } - - fn runtime_with_config<F>(configure: F) -> MycRuntime - where - F: FnOnce(&mut MycConfig), - { - let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = MycConfig::default(); - config.audit.default_read_limit = 2; - 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"); - configure(&mut config); - write_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - MycRuntime::bootstrap(config).expect("runtime") - } - - #[test] - fn granted_permissions_for_approval_respects_policy_ceiling() { - let runtime = runtime_with_config(|config| { - config.policy.permission_ceiling = "nip04_encrypt".parse().expect("permission ceiling"); - }); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection( - RadrootsNostrSignerConnectionDraft::new( - nostr::Keys::generate().public_key(), - runtime.user_public_identity(), - ) - .with_requested_permissions( - "nip44_encrypt".parse().expect("requested permissions"), - ), - ) - .expect("register connection"); - - let error = granted_permissions_for_approval( - runtime.signer_context().policy(), - &manager.list_connections().expect("connections"), - &connection.connection_id, - &[], - ) - .expect_err("requested permissions outside policy should be rejected"); - - assert!( - error - .to_string() - .contains("granted permissions exceed the configured policy ceiling") - ); - } - - #[test] - fn audit_output_surfaces_both_request_and_operation_records() { - let runtime = runtime(); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - nostr::Keys::generate().public_key(), - runtime.user_public_identity(), - )) - .expect("register connection"); - let request_evaluation = manager - .evaluate_request( - &connection.connection_id, - radroots_nostr_connect::message::RequestMessage::new("request-1", Request::Ping), - ) - .expect("record audit"); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::AuthReplayRestore, - MycOperationAuditOutcome::Restored, - Some(&connection.connection_id), - Some(request_evaluation.audit.request_id.as_str()), - 1, - 0, - "restored pending auth challenge after replay failure", - )); - - let output = load_audit_output( - &runtime, - &manager, - Some(connection.connection_id.as_str()), - None, - MycAuditScope::All, - None, - ) - .expect("load audit output"); - - assert_eq!(output.signer_request_audit, vec![request_evaluation.audit]); - assert_eq!(output.runtime_operation_audit.len(), 1); - assert_eq!( - output.runtime_operation_audit[0].operation, - MycOperationAuditKind::AuthReplayRestore - ); - } - - #[test] - fn audit_summary_counts_recent_failures_and_restores() { - let runtime = runtime(); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - nostr::Keys::generate().public_key(), - runtime.user_public_identity(), - )) - .expect("register connection"); - - let denied = manager - .evaluate_request( - &connection.connection_id, - radroots_nostr_connect::message::RequestMessage::new( - "request-1", - Request::SignEvent( - radroots_nostr_connect::message::UnsignedEvent::from_json( - &json!({ - "pubkey": runtime.user_identity().public_key().to_hex(), - "created_at": Timestamp::from(1).as_secs(), - "kind": 1, - "tags": [], - "content": "hello" - }) - .to_string(), - ) - .expect("unsigned event"), - ), - ), - ) - .expect("denied request"); - let challenged = manager - .require_auth_challenge(&connection.connection_id, "https://auth.example") - .expect("require auth challenge"); - let challenged_eval = manager - .evaluate_request( - &challenged.connection_id, - radroots_nostr_connect::message::RequestMessage::new("request-2", Request::Ping), - ) - .expect("challenged request"); - - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Rejected, - Some(&connection.connection_id), - Some("request-1"), - 1, - 0, - "listener publish rejected", - )); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::AuthReplayRestore, - MycOperationAuditOutcome::Restored, - Some(&connection.connection_id), - Some("request-2"), - 1, - 0, - "restored pending auth challenge after replay failure", - )); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ConnectAcceptPublish, - MycOperationAuditOutcome::Succeeded, - Some(&connection.connection_id), - Some("request-3"), - 1, - 1, - "publish succeeded", - )); - - let summary = summarize_audit_output( - &runtime, - &manager, - Some(connection.connection_id.as_str()), - None, - MycAuditScope::All, - None, - ) - .expect("summary"); - - assert_eq!(summary.record_limit, 2); - assert_eq!(summary.signer_request_total, 2); - assert_eq!(summary.signer_request_decisions.denied, 1); - assert_eq!(summary.signer_request_decisions.challenged, 1); - assert_eq!(summary.runtime_operation_total, 2); - assert_eq!(summary.runtime_operation_outcomes.succeeded, 1); - assert_eq!(summary.runtime_operation_outcomes.restored, 1); - assert_eq!(summary.runtime_aggregate_publish_rejection_count, 0); - assert_eq!(summary.runtime_repair_success_count, 0); - assert_eq!(summary.runtime_repair_rejection_count, 0); - assert_eq!(summary.runtime_unavailable_count, 0); - assert_eq!(summary.runtime_replay_restore_count, 1); - assert_eq!( - summary - .runtime_operation_by_kind - .get("auth_replay_restore") - .expect("restore kind") - .restored, - 1 - ); - assert_eq!( - summary - .runtime_operation_by_kind - .get("connect_accept_publish") - .expect("connect kind") - .succeeded, - 1 - ); - assert_eq!(denied.audit.request_id.as_str(), "request-1"); - assert_eq!(challenged_eval.audit.request_id.as_str(), "request-2"); - } - - #[test] - fn audit_summary_separates_repair_rejections_from_aggregate_publish_rejections() { - let runtime = runtime(); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - nostr::Keys::generate().public_key(), - runtime.user_public_identity(), - )) - .expect("register connection"); - - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerPublish, - MycOperationAuditOutcome::Succeeded, - Some(&connection.connection_id), - Some("request-1"), - 2, - 1, - "1/2 relays acknowledged publish; failures: relay-b: blocked", - )); - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRepair, - MycOperationAuditOutcome::Succeeded, - Some(&connection.connection_id), - Some("request-1"), - 1, - 1, - "relay repaired", - ) - .with_relay_url("wss://relay-a.example.com"), - ); - runtime.record_operation_audit( - &MycOperationAuditRecord::new( - MycOperationAuditKind::DiscoveryHandlerRepair, - MycOperationAuditOutcome::Rejected, - Some(&connection.connection_id), - Some("request-1"), - 1, - 0, - "blocked by relay", - ) - .with_relay_url("wss://relay-b.example.com"), - ); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Rejected, - Some(&connection.connection_id), - Some("request-2"), - 1, - 0, - "listener publish rejected", - )); - - let summary = summarize_audit_output( - &runtime, - &manager, - Some(connection.connection_id.as_str()), - None, - MycAuditScope::Operation, - Some(10), - ) - .expect("summary"); - - assert_eq!(summary.runtime_operation_total, 4); - assert_eq!(summary.runtime_aggregate_publish_rejection_count, 1); - assert_eq!(summary.runtime_repair_success_count, 1); - assert_eq!(summary.runtime_repair_rejection_count, 1); - assert_eq!(summary.runtime_replay_restore_count, 0); - assert_eq!( - summary - .runtime_operation_by_kind - .get("discovery_handler_publish") - .expect("publish kind") - .succeeded, - 1 - ); - assert_eq!( - summary - .runtime_operation_by_kind - .get("discovery_handler_repair") - .expect("repair kind") - .succeeded, - 1 - ); - assert_eq!( - summary - .runtime_operation_by_kind - .get("discovery_handler_repair") - .expect("repair kind") - .rejected, - 1 - ); - } - - #[test] - fn parses_signer_status_view() { - let cli = MycCli::try_parse_from(["myc", "status", "--view", "signer"]) - .expect("parse signer status"); - - assert!(matches!( - cli.command, - Some(MycCommand::Status { - view: MycStatusView::Signer - }) - )); - } - - #[test] - fn parses_custody_list_command() { - let status = MycCli::try_parse_from(["myc", "custody", "status", "--role", "signer"]) - .expect("parse custody status"); - assert!(matches!( - status.command, - Some(MycCommand::Custody { - command: MycCustodyCommand::Status { - role: MycCustodyRole::Signer - } - }) - )); - - let cli = MycCli::try_parse_from(["myc", "custody", "list", "--role", "signer"]) - .expect("parse custody list"); - - assert!(matches!( - cli.command, - Some(MycCommand::Custody { - command: MycCustodyCommand::List { - role: MycCustodyRole::Signer - } - }) - )); - } - - #[test] - fn parses_custody_generate_and_import_commands() { - let generate = MycCli::try_parse_from([ - "myc", "custody", "generate", "--role", "user", "--label", "primary", "--select", - ]) - .expect("parse custody generate"); - assert!(matches!( - generate.command, - Some(MycCommand::Custody { - command: MycCustodyCommand::Generate { - role: MycCustodyRole::User, - select: true, - .. - } - }) - )); - - let import = MycCli::try_parse_from([ - "myc", - "custody", - "import-file", - "--role", - "discovery-app", - "--path", - "/tmp/discovery.json", - ]) - .expect("parse custody import"); - assert!(matches!( - import.command, - Some(MycCommand::Custody { - command: MycCustodyCommand::ImportFile { - role: MycCustodyRole::DiscoveryApp, - select: false, - .. - } - }) - )); - - let export_nip49 = MycCli::try_parse_from([ - "myc", - "custody", - "export-nip49", - "--role", - "signer", - "--out", - "/tmp/signer.ncryptsec", - "--password-env", - "MYC_TEST_PASSWORD", - ]) - .expect("parse custody export-nip49"); - assert!(matches!( - export_nip49.command, - Some(MycCommand::Custody { - command: MycCustodyCommand::ExportNip49 { - role: MycCustodyRole::Signer, - .. - } - }) - )); - - let import_nip49 = MycCli::try_parse_from([ - "myc", - "custody", - "import-nip49", - "--role", - "user", - "--path", - "/tmp/user.ncryptsec", - "--password-env", - "MYC_TEST_PASSWORD", - "--label", - "migrated", - ]) - .expect("parse custody import-nip49"); - assert!(matches!( - import_nip49.command, - Some(MycCommand::Custody { - command: MycCustodyCommand::ImportNip49 { - role: MycCustodyRole::User, - .. - } - }) - )); - - let rotate = - MycCli::try_parse_from(["myc", "custody", "rotate", "--role", "discovery-app"]) - .expect("parse custody rotate"); - assert!(matches!( - rotate.command, - Some(MycCommand::Custody { - command: MycCustodyCommand::Rotate { - role: MycCustodyRole::DiscoveryApp - } - }) - )); - } -} diff --git a/src/config.rs b/src/config.rs @@ -1,5 +1,4 @@ use std::collections::BTreeSet; -use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -13,7 +12,7 @@ use tracing_subscriber::EnvFilter; use crate::error::MycError; use crate::paths::MycPathOverrideFlags; -pub use crate::paths::{DEFAULT_ENV_PATH, MycPathProfile, MycPathsConfig}; +pub use crate::paths::{MycPathProfile, MycPathsConfig}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -367,9 +366,9 @@ const MYC_ALLOWED_SHARED_SECRET_BACKENDS: [MycIdentityBackend; 4] = [ const MYC_RUNTIME_SPECIFIC_CUSTODY_MODES: [&str; 1] = ["managed_account"]; const MYC_DEFAULT_SHARED_SECRET_BACKEND: MycIdentityBackend = MycIdentityBackend::EncryptedFile; const MYC_HOST_VAULT_POLICY: &str = "desktop"; -const MYC_CANONICAL_ROOT_SELECTION: &str = "profile_root_env_or_repo_wrapper"; -const MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE: &str = "config_artifact"; -const MYC_LEAF_PATH_ENV_POSTURE: &str = "runtime_owned_leaf_overrides"; +const MYC_CANONICAL_ROOT_SELECTION: &str = "bootstrap_cli"; +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 { @@ -459,383 +458,6 @@ impl MycConfig { Ok(config) } - fn process_path_selection() -> Result<(MycPathProfile, Option<PathBuf>), MycError> { - crate::paths::process_path_selection() - } - - fn default_env_path_with_path_selection( - resolver: &RadrootsPathResolver, - profile: MycPathProfile, - repo_local_root: Option<&Path>, - ) -> Result<PathBuf, MycError> { - crate::paths::default_env_path_with_path_selection(resolver, profile, repo_local_root) - } - - pub fn load_from_default_env_path() -> Result<Self, MycError> { - let resolver = RadrootsPathResolver::current(); - let (profile, repo_local_root) = Self::process_path_selection()?; - let path = Self::default_env_path_with_path_selection( - &resolver, - profile, - repo_local_root.as_deref(), - )?; - Self::load_from_env_path_with_resolver(path, &resolver) - } - - pub fn load_from_env_path(path: impl AsRef<Path>) -> Result<Self, MycError> { - Self::load_from_env_path_with_resolver(path, &RadrootsPathResolver::current()) - } - - fn load_from_env_path_with_resolver( - path: impl AsRef<Path>, - resolver: &RadrootsPathResolver, - ) -> Result<Self, MycError> { - let path = path.as_ref(); - let value = fs::read_to_string(path).map_err(|source| MycError::ConfigIo { - path: path.to_path_buf(), - source, - })?; - Self::from_env_str_with_source_and_resolver(&value, path, resolver) - } - - pub fn from_env_str(value: &str) -> Result<Self, MycError> { - Self::from_env_str_with_source_and_resolver( - value, - Path::new("<inline>"), - &RadrootsPathResolver::current(), - ) - } - - pub fn to_env_string(&self) -> Result<String, MycError> { - self.validate()?; - - let mut lines = Vec::new(); - push_env_line( - &mut lines, - "MYC_SERVICE_INSTANCE_NAME", - self.service.instance_name.as_str(), - ); - push_env_line( - &mut lines, - "MYC_LOGGING_FILTER", - self.logging.filter.as_str(), - ); - push_optional_path_env_line( - &mut lines, - "MYC_LOGGING_OUTPUT_DIR", - self.logging.output_dir.as_ref(), - ); - push_env_line( - &mut lines, - "MYC_LOGGING_STDOUT", - self.logging.stdout.to_string(), - ); - push_env_line( - &mut lines, - "MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS", - self.custody.external_command_timeout_secs.to_string(), - ); - push_env_line(&mut lines, "MYC_PATHS_PROFILE", self.paths.profile.as_str()); - push_optional_path_env_line( - &mut lines, - "MYC_PATHS_REPO_LOCAL_ROOT", - self.paths.repo_local_root.as_ref(), - ); - push_env_line( - &mut lines, - "MYC_PATHS_STATE_DIR", - self.paths.state_dir.display().to_string(), - ); - push_env_line( - &mut lines, - "MYC_IDENTITY_SIGNER_BACKEND", - self.paths.signer_identity_backend.as_str(), - ); - push_env_line( - &mut lines, - "MYC_IDENTITY_SIGNER_PATH", - self.paths.signer_identity_path.display().to_string(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID", - self.paths.signer_identity_keyring_account_id.as_deref(), - ); - push_env_line( - &mut lines, - "MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME", - self.paths.signer_identity_keyring_service_name.as_str(), - ); - push_optional_path_env_line( - &mut lines, - "MYC_IDENTITY_SIGNER_PROFILE_PATH", - self.paths.signer_identity_profile_path.as_ref(), - ); - push_env_line( - &mut lines, - "MYC_IDENTITY_USER_BACKEND", - self.paths.user_identity_backend.as_str(), - ); - push_env_line( - &mut lines, - "MYC_IDENTITY_USER_PATH", - self.paths.user_identity_path.display().to_string(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID", - self.paths.user_identity_keyring_account_id.as_deref(), - ); - push_env_line( - &mut lines, - "MYC_IDENTITY_USER_KEYRING_SERVICE_NAME", - self.paths.user_identity_keyring_service_name.as_str(), - ); - push_optional_path_env_line( - &mut lines, - "MYC_IDENTITY_USER_PROFILE_PATH", - self.paths.user_identity_profile_path.as_ref(), - ); - push_env_line( - &mut lines, - "MYC_PERSISTENCE_SIGNER_STATE_BACKEND", - self.persistence.signer_state_backend.as_str(), - ); - push_env_line( - &mut lines, - "MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND", - self.persistence.runtime_audit_backend.as_str(), - ); - push_env_line( - &mut lines, - "MYC_AUDIT_DEFAULT_READ_LIMIT", - self.audit.default_read_limit.to_string(), - ); - push_env_line( - &mut lines, - "MYC_AUDIT_MAX_ACTIVE_FILE_BYTES", - self.audit.max_active_file_bytes.to_string(), - ); - push_env_line( - &mut lines, - "MYC_AUDIT_MAX_ARCHIVED_FILES", - self.audit.max_archived_files.to_string(), - ); - push_env_line( - &mut lines, - "MYC_OBSERVABILITY_ENABLED", - self.observability.enabled.to_string(), - ); - push_env_line( - &mut lines, - "MYC_OBSERVABILITY_BIND_ADDR", - self.observability.bind_addr.to_string(), - ); - push_env_line( - &mut lines, - "MYC_DISCOVERY_ENABLED", - self.discovery.enabled.to_string(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_DISCOVERY_DOMAIN", - self.discovery.domain.as_deref(), - ); - push_env_line( - &mut lines, - "MYC_DISCOVERY_HANDLER_IDENTIFIER", - self.discovery.handler_identifier.as_str(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_IDENTITY_DISCOVERY_APP_BACKEND", - self.discovery - .app_identity_backend - .map(MycIdentityBackend::as_str), - ); - push_optional_path_env_line( - &mut lines, - "MYC_IDENTITY_DISCOVERY_APP_PATH", - self.discovery.app_identity_path.as_ref(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID", - self.discovery.app_identity_keyring_account_id.as_deref(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME", - self.discovery.app_identity_keyring_service_name.as_deref(), - ); - push_optional_path_env_line( - &mut lines, - "MYC_IDENTITY_DISCOVERY_APP_PROFILE_PATH", - self.discovery.app_identity_profile_path.as_ref(), - ); - push_env_line( - &mut lines, - "MYC_DISCOVERY_PUBLIC_RELAY_URLS", - self.discovery.public_relays.join(","), - ); - push_env_line( - &mut lines, - "MYC_DISCOVERY_PUBLISH_RELAY_URLS", - self.discovery.publish_relays.join(","), - ); - push_optional_string_env_line( - &mut lines, - "MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE", - self.discovery.nostrconnect_url_template.as_deref(), - ); - push_optional_path_env_line( - &mut lines, - "MYC_DISCOVERY_NIP05_OUTPUT_PATH", - self.discovery.nip05_output_path.as_ref(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_DISCOVERY_METADATA_NAME", - self.discovery.metadata.name.as_deref(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_DISCOVERY_METADATA_DISPLAY_NAME", - self.discovery.metadata.display_name.as_deref(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_DISCOVERY_METADATA_ABOUT", - self.discovery.metadata.about.as_deref(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_DISCOVERY_METADATA_WEBSITE", - self.discovery.metadata.website.as_deref(), - ); - push_optional_string_env_line( - &mut lines, - "MYC_DISCOVERY_METADATA_PICTURE", - self.discovery.metadata.picture.as_deref(), - ); - push_env_line( - &mut lines, - "MYC_POLICY_CONNECTION_APPROVAL", - match self.policy.connection_approval { - MycConnectionApproval::NotRequired => "not_required", - MycConnectionApproval::ExplicitUser => "explicit_user", - MycConnectionApproval::Deny => "deny", - }, - ); - push_env_line( - &mut lines, - "MYC_POLICY_TRUSTED_CLIENT_PUBKEYS", - self.policy.trusted_client_pubkeys.join(","), - ); - push_env_line( - &mut lines, - "MYC_POLICY_DENIED_CLIENT_PUBKEYS", - self.policy.denied_client_pubkeys.join(","), - ); - push_env_line( - &mut lines, - "MYC_POLICY_PERMISSION_CEILING", - self.policy.permission_ceiling.to_string(), - ); - push_env_line( - &mut lines, - "MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS", - self.policy - .allowed_sign_event_kinds - .iter() - .map(u16::to_string) - .collect::<Vec<_>>() - .join(","), - ); - push_optional_string_env_line( - &mut lines, - "MYC_POLICY_AUTH_URL", - self.policy.auth_url.as_deref(), - ); - push_env_line( - &mut lines, - "MYC_POLICY_AUTH_PENDING_TTL_SECS", - self.policy.auth_pending_ttl_secs.to_string(), - ); - push_optional_u64_env_line( - &mut lines, - "MYC_POLICY_AUTHORIZED_TTL_SECS", - self.policy.auth_authorized_ttl_secs, - ); - push_optional_u64_env_line( - &mut lines, - "MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS", - self.policy.reauth_after_inactivity_secs, - ); - push_optional_u64_env_line( - &mut lines, - "MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS", - self.policy.connect_rate_limit_window_secs, - ); - push_optional_usize_env_line( - &mut lines, - "MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS", - self.policy.connect_rate_limit_max_attempts, - ); - push_optional_u64_env_line( - &mut lines, - "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS", - self.policy.auth_challenge_rate_limit_window_secs, - ); - push_optional_usize_env_line( - &mut lines, - "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS", - self.policy.auth_challenge_rate_limit_max_attempts, - ); - push_env_line( - &mut lines, - "MYC_TRANSPORT_ENABLED", - self.transport.enabled.to_string(), - ); - push_env_line( - &mut lines, - "MYC_TRANSPORT_CONNECT_TIMEOUT_SECS", - self.transport.connect_timeout_secs.to_string(), - ); - push_env_line( - &mut lines, - "MYC_TRANSPORT_RELAY_URLS", - self.transport.relays.join(","), - ); - push_env_line( - &mut lines, - "MYC_TRANSPORT_DELIVERY_POLICY", - self.transport.delivery_policy.as_str(), - ); - push_optional_usize_env_line( - &mut lines, - "MYC_TRANSPORT_DELIVERY_QUORUM", - self.transport.delivery_quorum, - ); - push_env_line( - &mut lines, - "MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS", - self.transport.publish_max_attempts.to_string(), - ); - push_env_line( - &mut lines, - "MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS", - self.transport.publish_initial_backoff_millis.to_string(), - ); - push_env_line( - &mut lines, - "MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS", - self.transport.publish_max_backoff_millis.to_string(), - ); - - Ok(lines.join("\n") + "\n") - } - pub fn validate(&self) -> Result<(), MycError> { if self.service.instance_name.trim().is_empty() { return Err(MycError::InvalidConfig( @@ -1025,558 +647,6 @@ impl MycConfig { 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) = - 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(); - for (key, value, line_number) in entries { - apply_env_entry( - &mut config, - &mut path_overrides, - key.as_str(), - value.as_str(), - path, - line_number, - )?; - } - crate::paths::apply_path_defaults(&mut config, resolver, &path_overrides)?; - config.validate()?; - Ok(config) - } -} - -fn push_env_line(lines: &mut Vec<String>, key: &str, value: impl ToString) { - lines.push(format!("{key}={}", value.to_string())); -} - -fn push_optional_string_env_line(lines: &mut Vec<String>, key: &str, value: Option<&str>) { - if let Some(value) = value { - push_env_line(lines, key, value); - } -} - -fn push_optional_path_env_line(lines: &mut Vec<String>, key: &str, value: Option<&PathBuf>) { - if let Some(value) = value { - push_env_line(lines, key, value.display().to_string()); - } -} - -fn push_optional_u64_env_line(lines: &mut Vec<String>, key: &str, value: Option<u64>) { - if let Some(value) = value { - push_env_line(lines, key, value.to_string()); - } -} - -fn push_optional_usize_env_line(lines: &mut Vec<String>, key: &str, value: Option<usize>) { - if let Some(value) = value { - push_env_line(lines, key, value.to_string()); - } -} - -fn parse_env_entries(value: &str, path: &Path) -> Result<Vec<(String, String, usize)>, MycError> { - let mut seen = BTreeSet::new(); - let mut entries = Vec::new(); - - for (index, raw_line) in value.lines().enumerate() { - let line_number = index + 1; - let line = raw_line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - let Some((key_raw, value_raw)) = raw_line.split_once('=') else { - return Err(config_parse_error( - path, - line_number, - "expected KEY=VALUE assignment", - )); - }; - let key = key_raw.trim(); - if key.is_empty() { - return Err(config_parse_error( - path, - line_number, - "environment variable name must not be empty", - )); - } - if !key.chars().all(|character| { - character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' - }) { - return Err(config_parse_error( - path, - line_number, - format!("invalid environment variable name `{key}`"), - )); - } - if !seen.insert(key.to_owned()) { - return Err(config_parse_error( - path, - line_number, - format!("duplicate environment variable `{key}`"), - )); - } - entries.push(( - key.to_owned(), - parse_env_value(value_raw.trim(), path, line_number)?, - line_number, - )); - } - - Ok(entries) -} - -fn parse_env_value(value: &str, path: &Path, line_number: usize) -> Result<String, MycError> { - if value.starts_with('"') || value.starts_with('\'') { - let quote = value.chars().next().expect("quoted env value prefix"); - if !value.ends_with(quote) || value.len() < 2 { - return Err(config_parse_error( - path, - line_number, - "unterminated quoted environment value", - )); - } - return Ok(value[1..value.len() - 1].to_owned()); - } - Ok(value.to_owned()) -} - -fn apply_env_entry( - config: &mut MycConfig, - path_overrides: &mut MycPathOverrideFlags, - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<(), MycError> { - match key { - "MYC_SERVICE_INSTANCE_NAME" => config.service.instance_name = value.to_owned(), - "MYC_LOGGING_FILTER" => config.logging.filter = value.to_owned(), - "MYC_LOGGING_OUTPUT_DIR" => { - config.logging.output_dir = parse_optional_path_env(value); - path_overrides.logging_output_dir = true; - } - "MYC_LOGGING_STDOUT" => { - config.logging.stdout = parse_bool_env(key, value, path, line_number)?; - } - "MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS" => { - config.custody.external_command_timeout_secs = - parse_u64_env(key, value, path, line_number)?; - } - "MYC_PATHS_PROFILE" => { - 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); - } - "MYC_PATHS_STATE_DIR" => { - config.paths.state_dir = PathBuf::from(value); - path_overrides.state_dir = true; - } - "MYC_IDENTITY_SIGNER_BACKEND" => { - config.paths.signer_identity_backend = - parse_identity_backend_env(key, value, path, line_number)?; - } - "MYC_IDENTITY_SIGNER_PATH" => { - config.paths.signer_identity_path = PathBuf::from(value); - path_overrides.signer_identity_path = true; - } - "MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID" => { - config.paths.signer_identity_keyring_account_id = parse_optional_string_env(value); - } - "MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME" => { - config.paths.signer_identity_keyring_service_name = value.to_owned(); - } - "MYC_IDENTITY_SIGNER_PROFILE_PATH" => { - config.paths.signer_identity_profile_path = parse_optional_path_env(value); - } - "MYC_IDENTITY_USER_BACKEND" => { - config.paths.user_identity_backend = - parse_identity_backend_env(key, value, path, line_number)?; - } - "MYC_IDENTITY_USER_PATH" => { - config.paths.user_identity_path = PathBuf::from(value); - path_overrides.user_identity_path = true; - } - "MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID" => { - config.paths.user_identity_keyring_account_id = parse_optional_string_env(value); - } - "MYC_IDENTITY_USER_KEYRING_SERVICE_NAME" => { - config.paths.user_identity_keyring_service_name = value.to_owned(); - } - "MYC_IDENTITY_USER_PROFILE_PATH" => { - config.paths.user_identity_profile_path = parse_optional_path_env(value); - } - "MYC_PERSISTENCE_SIGNER_STATE_BACKEND" => { - config.persistence.signer_state_backend = - parse_signer_state_backend_env(key, value, path, line_number)?; - } - "MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND" => { - config.persistence.runtime_audit_backend = - parse_runtime_audit_backend_env(key, value, path, line_number)?; - } - "MYC_AUDIT_DEFAULT_READ_LIMIT" => { - config.audit.default_read_limit = parse_usize_env(key, value, path, line_number)?; - } - "MYC_AUDIT_MAX_ACTIVE_FILE_BYTES" => { - config.audit.max_active_file_bytes = parse_u64_env(key, value, path, line_number)?; - } - "MYC_AUDIT_MAX_ARCHIVED_FILES" => { - config.audit.max_archived_files = parse_usize_env(key, value, path, line_number)?; - } - "MYC_OBSERVABILITY_ENABLED" => { - config.observability.enabled = parse_bool_env(key, value, path, line_number)?; - } - "MYC_OBSERVABILITY_BIND_ADDR" => { - config.observability.bind_addr = parse_socket_addr_env(key, value, path, line_number)?; - } - "MYC_DISCOVERY_ENABLED" => { - config.discovery.enabled = parse_bool_env(key, value, path, line_number)?; - } - "MYC_DISCOVERY_DOMAIN" => { - config.discovery.domain = parse_optional_string_env(value); - } - "MYC_DISCOVERY_HANDLER_IDENTIFIER" => { - config.discovery.handler_identifier = value.to_owned(); - } - "MYC_IDENTITY_DISCOVERY_APP_BACKEND" => { - config.discovery.app_identity_backend = - parse_optional_identity_backend_env(key, value, path, line_number)?; - } - "MYC_IDENTITY_DISCOVERY_APP_PATH" => { - config.discovery.app_identity_path = parse_optional_path_env(value); - path_overrides.discovery_app_identity_path = true; - } - "MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID" => { - config.discovery.app_identity_keyring_account_id = parse_optional_string_env(value); - } - "MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME" => { - config.discovery.app_identity_keyring_service_name = parse_optional_string_env(value); - } - "MYC_IDENTITY_DISCOVERY_APP_PROFILE_PATH" => { - config.discovery.app_identity_profile_path = parse_optional_path_env(value); - } - "MYC_DISCOVERY_PUBLIC_RELAY_URLS" => { - config.discovery.public_relays = parse_string_list_env(value); - } - "MYC_DISCOVERY_PUBLISH_RELAY_URLS" => { - config.discovery.publish_relays = parse_string_list_env(value); - } - "MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE" => { - config.discovery.nostrconnect_url_template = parse_optional_string_env(value); - } - "MYC_DISCOVERY_NIP05_OUTPUT_PATH" => { - config.discovery.nip05_output_path = parse_optional_path_env(value); - path_overrides.discovery_nip05_output_path = true; - } - "MYC_DISCOVERY_METADATA_NAME" => { - config.discovery.metadata.name = parse_optional_string_env(value); - } - "MYC_DISCOVERY_METADATA_DISPLAY_NAME" => { - config.discovery.metadata.display_name = parse_optional_string_env(value); - } - "MYC_DISCOVERY_METADATA_ABOUT" => { - config.discovery.metadata.about = parse_optional_string_env(value); - } - "MYC_DISCOVERY_METADATA_WEBSITE" => { - config.discovery.metadata.website = parse_optional_string_env(value); - } - "MYC_DISCOVERY_METADATA_PICTURE" => { - config.discovery.metadata.picture = parse_optional_string_env(value); - } - "MYC_POLICY_CONNECTION_APPROVAL" => { - config.policy.connection_approval = - parse_connection_approval_env(key, value, path, line_number)?; - } - "MYC_POLICY_TRUSTED_CLIENT_PUBKEYS" => { - config.policy.trusted_client_pubkeys = parse_string_list_env(value); - } - "MYC_POLICY_DENIED_CLIENT_PUBKEYS" => { - config.policy.denied_client_pubkeys = parse_string_list_env(value); - } - "MYC_POLICY_PERMISSION_CEILING" => { - config.policy.permission_ceiling = - parse_permissions_env(key, value, path, line_number)?; - } - "MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS" => { - config.policy.allowed_sign_event_kinds = - parse_u16_list_env(key, value, path, line_number)?; - } - "MYC_POLICY_AUTH_URL" => { - config.policy.auth_url = parse_optional_string_env(value); - } - "MYC_POLICY_AUTH_PENDING_TTL_SECS" => { - config.policy.auth_pending_ttl_secs = parse_u64_env(key, value, path, line_number)?; - } - "MYC_POLICY_AUTHORIZED_TTL_SECS" => { - config.policy.auth_authorized_ttl_secs = - Some(parse_u64_env(key, value, path, line_number)?); - } - "MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS" => { - config.policy.reauth_after_inactivity_secs = - Some(parse_u64_env(key, value, path, line_number)?); - } - "MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS" => { - config.policy.connect_rate_limit_window_secs = - Some(parse_u64_env(key, value, path, line_number)?); - } - "MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS" => { - config.policy.connect_rate_limit_max_attempts = - Some(parse_usize_env(key, value, path, line_number)?); - } - "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS" => { - config.policy.auth_challenge_rate_limit_window_secs = - Some(parse_u64_env(key, value, path, line_number)?); - } - "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS" => { - config.policy.auth_challenge_rate_limit_max_attempts = - Some(parse_usize_env(key, value, path, line_number)?); - } - "MYC_TRANSPORT_ENABLED" => { - config.transport.enabled = parse_bool_env(key, value, path, line_number)?; - } - "MYC_TRANSPORT_CONNECT_TIMEOUT_SECS" => { - config.transport.connect_timeout_secs = parse_u64_env(key, value, path, line_number)?; - } - "MYC_TRANSPORT_RELAY_URLS" => { - config.transport.relays = parse_string_list_env(value); - } - "MYC_TRANSPORT_DELIVERY_POLICY" => { - config.transport.delivery_policy = - parse_delivery_policy_env(key, value, path, line_number)?; - } - "MYC_TRANSPORT_DELIVERY_QUORUM" => { - config.transport.delivery_quorum = - Some(parse_usize_env(key, value, path, line_number)?); - } - "MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS" => { - config.transport.publish_max_attempts = parse_usize_env(key, value, path, line_number)?; - } - "MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS" => { - config.transport.publish_initial_backoff_millis = - parse_u64_env(key, value, path, line_number)?; - } - "MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS" => { - config.transport.publish_max_backoff_millis = - parse_u64_env(key, value, path, line_number)?; - } - _ => { - return Err(config_parse_error( - path, - line_number, - format!("unknown environment variable `{key}`"), - )); - } - } - - Ok(()) -} - -fn parse_bool_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<bool, MycError> { - value.parse::<bool>().map_err(|_| { - config_parse_error( - path, - line_number, - format!("{key} must be `true` or `false`"), - ) - }) -} - -fn parse_usize_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<usize, MycError> { - value.parse::<usize>().map_err(|_| { - config_parse_error( - path, - line_number, - format!("{key} must be an unsigned integer"), - ) - }) -} - -fn parse_u64_env(key: &str, value: &str, path: &Path, line_number: usize) -> Result<u64, MycError> { - value.parse::<u64>().map_err(|_| { - config_parse_error( - path, - line_number, - format!("{key} must be an unsigned integer"), - ) - }) -} - -fn parse_socket_addr_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<SocketAddr, MycError> { - value.parse::<SocketAddr>().map_err(|error| { - config_parse_error( - path, - line_number, - format!("{key} must be a socket address: {error}"), - ) - }) -} - -fn parse_connection_approval_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<MycConnectionApproval, MycError> { - match value { - "not_required" => Ok(MycConnectionApproval::NotRequired), - "explicit_user" => Ok(MycConnectionApproval::ExplicitUser), - "deny" => Ok(MycConnectionApproval::Deny), - _ => Err(config_parse_error( - path, - line_number, - format!("{key} must be `not_required`, `explicit_user`, or `deny`"), - )), - } -} - -fn parse_identity_backend_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<MycIdentityBackend, MycError> { - match value { - "encrypted_file" => Ok(MycIdentityBackend::EncryptedFile), - "host_vault" => Ok(MycIdentityBackend::HostVault), - "managed_account" => Ok(MycIdentityBackend::ManagedAccount), - "external_command" => Ok(MycIdentityBackend::ExternalCommand), - "plaintext_file" => Ok(MycIdentityBackend::PlaintextFile), - _ => Err(config_parse_error( - path, - line_number, - format!( - "{key} must be `encrypted_file`, `host_vault`, `managed_account`, `external_command`, or `plaintext_file`" - ), - )), - } -} - -fn parse_optional_identity_backend_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<Option<MycIdentityBackend>, MycError> { - match parse_optional_string_env(value) { - Some(value) => parse_identity_backend_env(key, value.as_str(), path, line_number).map(Some), - None => Ok(None), - } -} - -fn parse_delivery_policy_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<MycTransportDeliveryPolicy, MycError> { - match value { - "any" => Ok(MycTransportDeliveryPolicy::Any), - "quorum" => Ok(MycTransportDeliveryPolicy::Quorum), - "all" => Ok(MycTransportDeliveryPolicy::All), - _ => Err(config_parse_error( - path, - line_number, - format!("{key} must be `any`, `quorum`, or `all`"), - )), - } -} - -fn parse_signer_state_backend_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<MycSignerStateBackend, MycError> { - match value { - "json_file" => Ok(MycSignerStateBackend::JsonFile), - "sqlite" => Ok(MycSignerStateBackend::Sqlite), - _ => Err(config_parse_error( - path, - line_number, - format!("{key} must be `json_file` or `sqlite`"), - )), - } -} - -fn parse_runtime_audit_backend_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<MycRuntimeAuditBackend, MycError> { - match value { - "jsonl_file" => Ok(MycRuntimeAuditBackend::JsonlFile), - "sqlite" => Ok(MycRuntimeAuditBackend::Sqlite), - _ => Err(config_parse_error( - path, - line_number, - format!("{key} must be `jsonl_file` or `sqlite`"), - )), - } -} - -fn parse_optional_string_env(value: &str) -> Option<String> { - let value = value.trim(); - if value.is_empty() { - None - } else { - Some(value.to_owned()) - } -} - -fn parse_permissions_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<Permissions, MycError> { - value.parse::<Permissions>().map_err(|error| { - config_parse_error(path, line_number, format!("{key} parse error: {error}")) - }) -} - -fn parse_u16_list_env( - key: &str, - value: &str, - path: &Path, - line_number: usize, -) -> Result<Vec<u16>, MycError> { - parse_string_list_env(value) - .into_iter() - .map(|fragment| { - fragment.parse::<u16>().map_err(|_| { - config_parse_error( - path, - line_number, - format!("{key} must contain only unsigned 16-bit integers"), - ) - }) - }) - .collect() } fn validate_optional_rate_limit( @@ -1621,31 +691,6 @@ fn normalize_policy_client_pubkeys(values: &[String]) -> Result<BTreeSet<String> .collect() } -pub(crate) fn parse_optional_path_env(value: &str) -> Option<PathBuf> { - parse_optional_string_env(value).map(PathBuf::from) -} - -fn parse_string_list_env(value: &str) -> Vec<String> { - value - .split(',') - .map(str::trim) - .filter(|entry| !entry.is_empty()) - .map(ToOwned::to_owned) - .collect() -} - -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, - message: message.into(), - } -} - fn validate_identity_source_config( label: &str, source: &MycIdentitySourceSpec, @@ -1986,8 +1031,6 @@ fn discovery_host_is_local(host: Option<&str>) -> bool { #[cfg(test)] mod tests { - use std::fs; - use crate::paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; use super::*; @@ -2016,10 +1059,6 @@ mod tests { assert_eq!(config.paths.profile, MycPathProfile::InteractiveUser); assert_eq!(config.paths.repo_local_root, None); assert_eq!( - config.paths.config_env_path, - PathBuf::from("/home/treesap/.radroots/config/services/myc/config.env") - ); - assert_eq!( config.paths.run_dir, PathBuf::from("/home/treesap/.radroots/run/services/myc") ); @@ -2122,202 +1161,6 @@ mod tests { } #[test] - fn parse_config_from_env_overrides_defaults() { - let resolver = linux_resolver("/home/treesap"); - let config = MycConfig::from_env_str_with_source_and_resolver( - r#" -MYC_SERVICE_INSTANCE_NAME=myc-dev -MYC_LOGGING_FILTER=debug,myc=trace -MYC_LOGGING_OUTPUT_DIR=/tmp/myc-logs -MYC_LOGGING_STDOUT=false -MYC_PATHS_STATE_DIR=/tmp/myc -MYC_IDENTITY_SIGNER_BACKEND=encrypted_file -MYC_IDENTITY_SIGNER_PATH=/tmp/myc-identity.json -MYC_IDENTITY_USER_BACKEND=encrypted_file -MYC_IDENTITY_USER_PATH=/tmp/myc-user.json -MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file -MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file -MYC_AUDIT_DEFAULT_READ_LIMIT=50 -MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096 -MYC_AUDIT_MAX_ARCHIVED_FILES=3 -MYC_OBSERVABILITY_ENABLED=true -MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550 -MYC_DISCOVERY_ENABLED=true -MYC_DISCOVERY_DOMAIN=myc.example.com -MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main -MYC_IDENTITY_DISCOVERY_APP_BACKEND=encrypted_file -MYC_IDENTITY_DISCOVERY_APP_PATH=/tmp/myc-app.json -MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.discovery.example.com -MYC_DISCOVERY_PUBLISH_RELAY_URLS=wss://relay.publish.example.com -MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect> -MYC_DISCOVERY_NIP05_OUTPUT_PATH=/tmp/nostr.json -MYC_DISCOVERY_METADATA_NAME=myc -MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza -MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer -MYC_DISCOVERY_METADATA_WEBSITE=https://myc.example.com -MYC_DISCOVERY_METADATA_PICTURE=https://myc.example.com/logo.png -MYC_POLICY_CONNECTION_APPROVAL=not_required -MYC_POLICY_TRUSTED_CLIENT_PUBKEYS=1111111111111111111111111111111111111111111111111111111111111111 -MYC_POLICY_DENIED_CLIENT_PUBKEYS=2222222222222222222222222222222222222222222222222222222222222222 -MYC_POLICY_PERMISSION_CEILING=nip04_encrypt,sign_event:1 -MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS=1,7 -MYC_POLICY_AUTH_URL=https://auth.example.com/challenge -MYC_POLICY_AUTH_PENDING_TTL_SECS=300 -MYC_POLICY_AUTHORIZED_TTL_SECS=3600 -MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 -MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60 -MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5 -MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120 -MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3 -MYC_TRANSPORT_ENABLED=true -MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15 -MYC_TRANSPORT_RELAY_URLS=wss://relay.example.com,wss://relay2.example.com -MYC_TRANSPORT_DELIVERY_POLICY=quorum -MYC_TRANSPORT_DELIVERY_QUORUM=2 -MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS=4 -MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS=100 -MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800 - "#, - Path::new("inline.env"), - &resolver, - ) - .expect("config"); - - assert_eq!(config.service.instance_name, "myc-dev"); - assert_eq!(config.logging.filter, "debug,myc=trace"); - assert_eq!(config.paths.profile, MycPathProfile::InteractiveUser); - assert_eq!( - config.paths.config_env_path, - PathBuf::from("/home/treesap/.radroots/config/services/myc/config.env") - ); - assert_eq!( - config.paths.run_dir, - PathBuf::from("/home/treesap/.radroots/run/services/myc") - ); - assert_eq!( - config.logging.output_dir, - Some(PathBuf::from("/tmp/myc-logs")) - ); - assert!(!config.logging.stdout); - assert_eq!(config.paths.state_dir, PathBuf::from("/tmp/myc")); - assert_eq!( - config.paths.signer_identity_backend, - MycIdentityBackend::EncryptedFile - ); - assert_eq!( - config.paths.signer_identity_path, - PathBuf::from("/tmp/myc-identity.json") - ); - assert_eq!( - config.paths.user_identity_backend, - MycIdentityBackend::EncryptedFile - ); - assert_eq!( - config.paths.user_identity_path, - PathBuf::from("/tmp/myc-user.json") - ); - assert_eq!( - config.persistence.signer_state_backend, - MycSignerStateBackend::JsonFile - ); - assert_eq!( - config.persistence.runtime_audit_backend, - MycRuntimeAuditBackend::JsonlFile - ); - assert_eq!(config.audit.default_read_limit, 50); - assert_eq!(config.audit.max_active_file_bytes, 4096); - assert_eq!(config.audit.max_archived_files, 3); - assert!(config.observability.enabled); - assert_eq!( - config.observability.bind_addr, - "127.0.0.1:9550".parse().expect("observability bind addr") - ); - assert!(config.discovery.enabled); - assert_eq!(config.discovery.domain.as_deref(), Some("myc.example.com")); - assert_eq!(config.discovery.handler_identifier, "myc-main"); - assert_eq!( - config.discovery.app_identity_backend, - Some(MycIdentityBackend::EncryptedFile) - ); - assert_eq!( - config.discovery.app_identity_path, - Some(PathBuf::from("/tmp/myc-app.json")) - ); - assert_eq!( - config.discovery.public_relays, - vec!["wss://relay.discovery.example.com".to_owned()] - ); - assert_eq!( - config.discovery.publish_relays, - vec!["wss://relay.publish.example.com".to_owned()] - ); - assert_eq!( - config.discovery.nostrconnect_url_template.as_deref(), - Some("https://myc.example.com/connect/<nostrconnect>") - ); - assert_eq!( - config.discovery.nip05_output_path, - Some(PathBuf::from("/tmp/nostr.json")) - ); - assert_eq!(config.discovery.metadata.name.as_deref(), Some("myc")); - assert_eq!( - config.discovery.metadata.display_name.as_deref(), - Some("Mycorrhiza") - ); - assert_eq!( - config.policy.connection_approval, - MycConnectionApproval::NotRequired - ); - assert_eq!( - config.policy.trusted_client_pubkeys, - vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()] - ); - assert_eq!( - config.policy.denied_client_pubkeys, - vec!["2222222222222222222222222222222222222222222222222222222222222222".to_owned()] - ); - assert_eq!( - config.policy.permission_ceiling.to_string(), - "nip04_encrypt,sign_event:1" - ); - assert_eq!(config.policy.allowed_sign_event_kinds, vec![1, 7]); - assert_eq!( - config.policy.auth_url.as_deref(), - Some("https://auth.example.com/challenge") - ); - assert_eq!(config.policy.auth_pending_ttl_secs, 300); - assert_eq!(config.policy.auth_authorized_ttl_secs, Some(3600)); - assert_eq!(config.policy.reauth_after_inactivity_secs, Some(600)); - assert_eq!(config.policy.connect_rate_limit_window_secs, Some(60)); - assert_eq!(config.policy.connect_rate_limit_max_attempts, Some(5)); - assert_eq!( - config.policy.auth_challenge_rate_limit_window_secs, - Some(120) - ); - assert_eq!( - config.policy.auth_challenge_rate_limit_max_attempts, - Some(3) - ); - assert!(config.transport.enabled); - assert_eq!(config.transport.connect_timeout_secs, 15); - assert_eq!( - config.transport.relays, - vec![ - "wss://relay.example.com".to_owned(), - "wss://relay2.example.com".to_owned() - ] - ); - assert_eq!( - config.transport.delivery_policy, - MycTransportDeliveryPolicy::Quorum - ); - assert_eq!(config.transport.delivery_quorum, Some(2)); - assert_eq!(config.transport.publish_max_attempts, 4); - assert_eq!(config.transport.publish_initial_backoff_millis, 100); - assert_eq!(config.transport.publish_max_backoff_millis, 800); - } - - #[test] fn service_host_profile_uses_canonical_defaults() { let resolver = linux_resolver("/home/treesap"); let config = @@ -2326,10 +1169,6 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800 assert_eq!(config.paths.profile, MycPathProfile::ServiceHost); assert_eq!( - config.paths.config_env_path, - PathBuf::from("/etc/radroots/services/myc/config.env") - ); - assert_eq!( config.logging.output_dir, Some(PathBuf::from("/var/log/radroots/services/myc")) ); @@ -2371,10 +1210,6 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800 assert_eq!(config.paths.profile, MycPathProfile::RepoLocal); assert_eq!(config.paths.repo_local_root, Some(repo_local_root.clone())); assert_eq!( - config.paths.config_env_path, - repo_local_root.join("config/services/myc/config.env") - ); - assert_eq!( config.logging.output_dir, Some(repo_local_root.join("logs/services/myc")) ); @@ -2401,64 +1236,6 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800 } #[test] - fn load_from_missing_env_path_fails() { - let temp = tempfile::tempdir().expect("tempdir"); - let err = MycConfig::load_from_env_path(temp.path().join("missing.env")) - .expect_err("missing env"); - - assert!(err.to_string().contains("config io error")); - } - - #[test] - fn parse_rejects_unknown_env_keys() { - let err = MycConfig::from_env_str( - r#" -MYC_SERVICE_INSTANCE_NAME=myc-dev -MYC_UNKNOWN=nope - "#, - ) - .expect_err("unknown key"); - - assert!(err.to_string().contains("config parse error")); - } - - #[test] - fn parse_rejects_retired_env_keys() { - for key in [ - "MYC_PATHS_SIGNER_IDENTITY_BACKEND", - "MYC_PATHS_SIGNER_IDENTITY_PATH", - "MYC_PATHS_USER_IDENTITY_BACKEND", - "MYC_PATHS_USER_IDENTITY_PATH", - "MYC_DISCOVERY_APP_IDENTITY_BACKEND", - "MYC_DISCOVERY_APP_IDENTITY_PATH", - "MYC_DISCOVERY_PUBLIC_RELAYS", - "MYC_DISCOVERY_PUBLISH_RELAYS", - "MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE", - "MYC_TRANSPORT_RELAYS", - "MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MILLIS", - "MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS", - ] { - let err = MycConfig::from_env_str(format!("{key}=value\n").as_str()) - .expect_err("retired key should be rejected"); - assert!(err.to_string().contains("unknown environment variable")); - assert!(err.to_string().contains(key)); - } - } - - #[test] - fn parse_rejects_retired_identity_backend_aliases() { - for value in ["filesystem", "os_keyring"] { - let err = - MycConfig::from_env_str(format!("MYC_IDENTITY_SIGNER_BACKEND={value}\n").as_str()) - .expect_err("retired backend alias should be rejected"); - assert!( - err.to_string() - .contains("MYC_IDENTITY_SIGNER_BACKEND must be") - ); - } - } - - #[test] fn validate_rejects_enabled_transport_without_relays() { let mut config = MycConfig::default(); config.transport.enabled = true; @@ -2641,363 +1418,6 @@ MYC_UNKNOWN=nope } #[test] - fn parse_and_validate_host_vault_identity_backends() { - let config = MycConfig::from_env_str( - r#" -MYC_IDENTITY_SIGNER_BACKEND=host_vault -MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df -MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer -MYC_IDENTITY_USER_BACKEND=host_vault -MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID=e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af -MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.test.user -MYC_DISCOVERY_ENABLED=true -MYC_DISCOVERY_DOMAIN=myc.example.com -MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.example.com -MYC_IDENTITY_DISCOVERY_APP_BACKEND=host_vault -MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID=585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df -MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery - "#, - ) - .expect("config"); - - assert_eq!( - config.paths.signer_identity_backend, - MycIdentityBackend::HostVault - ); - assert_eq!( - config.paths.signer_identity_keyring_account_id.as_deref(), - Some("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") - ); - assert_eq!( - config.paths.user_identity_backend, - MycIdentityBackend::HostVault - ); - assert_eq!( - config.discovery.app_identity_backend, - Some(MycIdentityBackend::HostVault) - ); - assert_eq!( - config - .discovery - .app_identity_keyring_service_name - .as_deref(), - Some("org.radroots.myc.test.discovery") - ); - } - - #[test] - fn parse_and_validate_managed_account_identity_backends() { - let config = MycConfig::from_env_str( - r#" -MYC_IDENTITY_SIGNER_BACKEND=managed_account -MYC_IDENTITY_SIGNER_PATH=/var/lib/myc/custody/signer-accounts.json -MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer -MYC_IDENTITY_USER_BACKEND=managed_account -MYC_IDENTITY_USER_PATH=/var/lib/myc/custody/user-accounts.json -MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.test.user -MYC_DISCOVERY_ENABLED=true -MYC_DISCOVERY_DOMAIN=myc.example.com -MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.example.com -MYC_IDENTITY_DISCOVERY_APP_BACKEND=managed_account -MYC_IDENTITY_DISCOVERY_APP_PATH=/var/lib/myc/custody/discovery-accounts.json -MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery - "#, - ) - .expect("config"); - - assert_eq!( - config.paths.signer_identity_backend, - MycIdentityBackend::ManagedAccount - ); - assert_eq!( - config.paths.signer_identity_source().path, - Some(PathBuf::from("/var/lib/myc/custody/signer-accounts.json")) - ); - assert_eq!( - config - .paths - .signer_identity_source() - .keyring_service_name - .as_deref(), - Some("org.radroots.myc.test.signer") - ); - assert_eq!( - config.paths.user_identity_backend, - MycIdentityBackend::ManagedAccount - ); - assert_eq!( - config.discovery.app_identity_backend, - Some(MycIdentityBackend::ManagedAccount) - ); - assert_eq!( - config - .discovery - .app_identity_source() - .expect("app identity source") - .path, - Some(PathBuf::from( - "/var/lib/myc/custody/discovery-accounts.json" - )) - ); - } - - #[test] - fn parse_and_validate_external_command_identity_backends() { - let config = MycConfig::from_env_str( - r#" -MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=21 -MYC_IDENTITY_SIGNER_BACKEND=external_command -MYC_IDENTITY_SIGNER_PATH=/usr/local/libexec/myc-signer-helper -MYC_IDENTITY_USER_BACKEND=external_command -MYC_IDENTITY_USER_PATH=/usr/local/libexec/myc-user-helper -MYC_DISCOVERY_ENABLED=true -MYC_DISCOVERY_DOMAIN=myc.example.com -MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.example.com -MYC_IDENTITY_DISCOVERY_APP_BACKEND=external_command -MYC_IDENTITY_DISCOVERY_APP_PATH=/usr/local/libexec/myc-discovery-helper - "#, - ) - .expect("config"); - - assert_eq!( - config.paths.signer_identity_backend, - MycIdentityBackend::ExternalCommand - ); - assert_eq!( - config.paths.signer_identity_source().path, - Some(PathBuf::from("/usr/local/libexec/myc-signer-helper")) - ); - assert_eq!( - config.paths.user_identity_backend, - MycIdentityBackend::ExternalCommand - ); - assert_eq!( - config.discovery.app_identity_backend, - Some(MycIdentityBackend::ExternalCommand) - ); - assert_eq!(config.custody.external_command_timeout_secs, 21); - assert_eq!( - config - .discovery - .app_identity_source() - .expect("app identity source") - .path, - Some(PathBuf::from("/usr/local/libexec/myc-discovery-helper")) - ); - } - - #[test] - fn example_env_parses_and_validates() { - let example = - fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example")) - .expect("read example config"); - - let resolver = linux_resolver("/home/treesap"); - let config = MycConfig::from_env_str_with_source_and_resolver( - &example, - Path::new(".env.example"), - &resolver, - ) - .expect("example config"); - - assert_eq!(config.service.instance_name, "myc"); - assert_eq!(config.paths.profile, MycPathProfile::ServiceHost); - assert!(config.discovery.enabled); - assert_eq!(config.discovery.domain.as_deref(), Some("myc.radroots.org")); - assert_eq!(config.discovery.handler_identifier, "myc"); - assert_eq!( - config.logging.output_dir, - Some(PathBuf::from("/var/log/radroots/services/myc")) - ); - assert_eq!( - config.paths.config_env_path, - PathBuf::from("/etc/radroots/services/myc/config.env") - ); - assert_eq!( - config.paths.state_dir, - PathBuf::from("/var/lib/radroots/services/myc/state") - ); - assert_eq!( - config.paths.signer_identity_path, - PathBuf::from("/etc/radroots/secrets/services/myc/signer-identity.json") - ); - assert_eq!( - config.paths.user_identity_path, - PathBuf::from("/etc/radroots/secrets/services/myc/user-identity.json") - ); - assert_eq!(config.custody.external_command_timeout_secs, 10); - assert_eq!( - config.transport.delivery_policy, - MycTransportDeliveryPolicy::Any - ); - assert_eq!( - config.policy.connection_approval, - MycConnectionApproval::ExplicitUser - ); - assert_eq!( - config.persistence.signer_state_backend, - MycSignerStateBackend::JsonFile - ); - assert_eq!( - config.persistence.runtime_audit_backend, - MycRuntimeAuditBackend::JsonlFile - ); - assert_eq!(config.policy.auth_pending_ttl_secs, 900); - assert_eq!(config.transport.delivery_quorum, None); - assert_eq!(config.transport.publish_max_attempts, 1); - assert_eq!(config.transport.publish_initial_backoff_millis, 250); - assert_eq!(config.transport.publish_max_backoff_millis, 2_000); - assert_eq!( - config.discovery.nip05_output_path, - Some(PathBuf::from( - "/var/lib/radroots/services/myc/public/.well-known/nostr.json" - )) - ); - } - - #[test] - fn env_renderer_roundtrips_current_config_surface() { - let config = MycConfig::from_env_str( - r#" -MYC_SERVICE_INSTANCE_NAME=myc-dev -MYC_LOGGING_FILTER=debug,myc=trace -MYC_LOGGING_OUTPUT_DIR=/tmp/myc logs -MYC_LOGGING_STDOUT=false -MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=17 -MYC_PATHS_STATE_DIR=/tmp/myc state -MYC_IDENTITY_SIGNER_BACKEND=host_vault -MYC_IDENTITY_SIGNER_PATH=/tmp/ignored-signer.json -MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df -MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer -MYC_IDENTITY_SIGNER_PROFILE_PATH=/tmp/signer-profile.json -MYC_IDENTITY_USER_BACKEND=plaintext_file -MYC_IDENTITY_USER_PATH=/tmp/myc-user.json -MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.test.user -MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file -MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file -MYC_AUDIT_DEFAULT_READ_LIMIT=50 -MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096 -MYC_AUDIT_MAX_ARCHIVED_FILES=3 -MYC_OBSERVABILITY_ENABLED=true -MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550 -MYC_DISCOVERY_ENABLED=true -MYC_DISCOVERY_DOMAIN=myc.example.com -MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main -MYC_IDENTITY_DISCOVERY_APP_BACKEND=plaintext_file -MYC_IDENTITY_DISCOVERY_APP_PATH=/tmp/myc-app.json -MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery -MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.discovery.example.com -MYC_DISCOVERY_PUBLISH_RELAY_URLS=wss://relay.publish.example.com -MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect> -MYC_DISCOVERY_NIP05_OUTPUT_PATH=/tmp/nostr.json -MYC_DISCOVERY_METADATA_NAME=myc -MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza -MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer -MYC_DISCOVERY_METADATA_WEBSITE=https://myc.example.com -MYC_DISCOVERY_METADATA_PICTURE=https://myc.example.com/logo.png -MYC_POLICY_CONNECTION_APPROVAL=not_required -MYC_POLICY_TRUSTED_CLIENT_PUBKEYS=1111111111111111111111111111111111111111111111111111111111111111 -MYC_POLICY_DENIED_CLIENT_PUBKEYS=2222222222222222222222222222222222222222222222222222222222222222 -MYC_POLICY_PERMISSION_CEILING=nip04_encrypt,sign_event:1 -MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS=1,7 -MYC_POLICY_AUTH_URL=https://auth.example.com/challenge -MYC_POLICY_AUTH_PENDING_TTL_SECS=300 -MYC_POLICY_AUTHORIZED_TTL_SECS=3600 -MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 -MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60 -MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5 -MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120 -MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3 -MYC_TRANSPORT_ENABLED=true -MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15 -MYC_TRANSPORT_RELAY_URLS=wss://relay.example.com,wss://relay2.example.com -MYC_TRANSPORT_DELIVERY_POLICY=quorum -MYC_TRANSPORT_DELIVERY_QUORUM=2 -MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS=4 -MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS=100 -MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800 - "#, - ) - .expect("config"); - - let rendered = config.to_env_string().expect("render env"); - let reparsed = MycConfig::from_env_str(&rendered).expect("reparse rendered env"); - - assert!(rendered.contains("MYC_IDENTITY_SIGNER_BACKEND=host_vault")); - assert!(rendered.contains("MYC_IDENTITY_USER_BACKEND=plaintext_file")); - assert!(rendered.contains("MYC_IDENTITY_DISCOVERY_APP_BACKEND=plaintext_file")); - assert!( - rendered.contains("MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.discovery.example.com") - ); - assert!( - rendered.contains("MYC_DISCOVERY_PUBLISH_RELAY_URLS=wss://relay.publish.example.com") - ); - assert!(rendered.contains("MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect>")); - assert!( - rendered.contains( - "MYC_TRANSPORT_RELAY_URLS=wss://relay.example.com,wss://relay2.example.com" - ) - ); - assert!(rendered.contains("MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS=100")); - assert!(rendered.contains("MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800")); - assert!(!rendered.contains("MYC_PATHS_SIGNER_IDENTITY")); - assert!(!rendered.contains("MYC_PATHS_USER_IDENTITY")); - assert!(!rendered.contains("MYC_DISCOVERY_APP_IDENTITY")); - assert!(!rendered.contains("MYC_DISCOVERY_PUBLIC_RELAYS")); - assert!(!rendered.contains("MYC_DISCOVERY_PUBLISH_RELAYS")); - assert!(!rendered.contains("MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE")); - assert!(!rendered.contains("MYC_TRANSPORT_RELAYS")); - assert!(!rendered.contains("_MILLIS")); - assert_eq!(reparsed, config); - } - - #[test] - fn parse_runtime_audit_backend_supports_sqlite() { - let config = MycConfig::from_env_str( - r#" -MYC_IDENTITY_SIGNER_PATH=/tmp/signer.json -MYC_IDENTITY_USER_PATH=/tmp/user.json -MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite - "#, - ) - .expect("config"); - - assert_eq!( - config.persistence.runtime_audit_backend, - MycRuntimeAuditBackend::Sqlite - ); - assert!( - config - .to_env_string() - .expect("render env") - .contains("MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite") - ); - } - - #[test] - fn parse_signer_state_backend_supports_sqlite() { - let config = MycConfig::from_env_str( - r#" -MYC_IDENTITY_SIGNER_PATH=/tmp/signer.json -MYC_IDENTITY_USER_PATH=/tmp/user.json -MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite - "#, - ) - .expect("config"); - - assert_eq!( - config.persistence.signer_state_backend, - MycSignerStateBackend::Sqlite - ); - assert!( - config - .to_env_string() - .expect("render env") - .contains("MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite") - ); - } - - #[test] fn runtime_contract_output_matches_shared_runtime_contract() { let config = MycConfig::default(); let contract = config.runtime_contract_output(); @@ -3019,15 +1439,12 @@ MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite assert_eq!(contract.host_vault_policy, MycConfig::host_vault_policy()); assert_eq!( contract.path_overrides.canonical_root_selection, - "profile_root_env_or_repo_wrapper" + "bootstrap_cli" ); assert_eq!( contract.path_overrides.canonical_subordinate_path_override, - "config_artifact" - ); - assert_eq!( - contract.path_overrides.leaf_path_env_posture, - "runtime_owned_leaf_overrides" + "config_document_cli_only" ); + assert_eq!(contract.path_overrides.leaf_path_env_posture, "forbidden"); } } diff --git a/src/error.rs b/src/error.rs @@ -13,18 +13,6 @@ use crate::config::MycTransportDeliveryPolicy; #[derive(Debug, Error)] pub enum MycError { - #[error("config io error at {path}: {source}")] - ConfigIo { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("config parse error at {path}:{line_number}: {message}")] - ConfigParse { - path: PathBuf, - line_number: usize, - message: String, - }, #[error("invalid config: {0}")] InvalidConfig(String), #[error("invalid operation: {0}")] diff --git a/src/lib.rs b/src/lib.rs @@ -4,7 +4,6 @@ pub mod accounts; pub mod app; pub mod audit; mod audit_sqlite; -pub mod cli; mod cli_v1; pub mod config; mod config_v1; @@ -41,11 +40,11 @@ pub use cli_v1::{ parse_myc_cli_v1_from, }; pub use config::{ - DEFAULT_ENV_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycCustodyConfig, - MycDiscoveryConfig, MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, - MycLoggingConfig, MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, - MycPolicyConfig, MycRuntimeAuditBackend, MycRuntimeContractOutput, MycServiceConfig, - MycSignerStateBackend, MycTransportConfig, MycTransportDeliveryPolicy, + MycAuditConfig, MycConfig, MycConnectionApproval, MycCustodyConfig, MycDiscoveryConfig, + MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig, + MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, MycPolicyConfig, + MycRuntimeAuditBackend, MycRuntimeContractOutput, MycServiceConfig, MycSignerStateBackend, + MycTransportConfig, MycTransportDeliveryPolicy, }; pub use config_v1::{ MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION, @@ -97,13 +96,3 @@ pub use persistence::{ }; pub use policy::{MycConnectDecision, MycPolicyContext}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; - -pub async fn run() -> Result<(), MycError> { - let config = MycConfig::load_from_default_env_path()?; - logging::init_logging(&config.logging)?; - MycApp::bootstrap(config)?.run().await -} - -pub async fn run_cli() -> Result<(), MycError> { - cli::run_from_env().await -} diff --git a/src/logging.rs b/src/logging.rs @@ -61,30 +61,18 @@ pub fn init_logging(config: &MycLoggingConfig) -> Result<(), MycError> { mod tests { use std::path::PathBuf; - use crate::config::MycConfig; + use crate::config::MycLoggingConfig; #[test] - fn config_parses_logging_output_dir_and_stdout() { - let config = MycConfig::from_env_str( - r#" -MYC_LOGGING_FILTER=info,myc=debug -MYC_LOGGING_OUTPUT_DIR=/tmp/myc-logs -MYC_LOGGING_STDOUT=false -MYC_PATHS_STATE_DIR=/tmp/myc -MYC_IDENTITY_SIGNER_PATH=/tmp/signer.json -MYC_IDENTITY_USER_PATH=/tmp/user.json -MYC_DISCOVERY_ENABLED=false -MYC_TRANSPORT_ENABLED=false -MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 - "#, - ) - .expect("config"); + fn explicit_logging_configuration_preserves_values() { + let config = MycLoggingConfig { + filter: "info,myc=debug".to_owned(), + output_dir: Some(PathBuf::from("/tmp/myc-logs")), + stdout: false, + }; - assert_eq!( - config.logging.output_dir, - Some(PathBuf::from("/tmp/myc-logs")) - ); - assert!(!config.logging.stdout); + assert_eq!(config.output_dir, Some(PathBuf::from("/tmp/myc-logs"))); + assert!(!config.stdout); } #[test] diff --git a/src/main.rs b/src/main.rs @@ -1,27 +1,16 @@ #![forbid(unsafe_code)] -use serde_json::json; +use std::process::ExitCode; -#[tokio::main] -async fn main() { - if let Err(err) = myc::run_cli().await { - eprintln!("myc: {err}"); - if let Some(attempt_id) = err.discovery_refresh_attempt_id() { - eprintln!("myc: discovery repair attempt id: {attempt_id}"); - eprintln!( - "myc: inspect with `myc audit discovery-repair-attempt --attempt-id {attempt_id}`" - ); - let hint = json!({ - "attempt_id": attempt_id, - "inspect_args": ["audit", "discovery-repair-attempt", "--attempt-id", attempt_id], - }); - match serde_json::to_string(&hint) { - Ok(value) => eprintln!("myc: discovery repair attempt json: {value}"), - Err(json_error) => eprintln!( - "myc: failed to serialize discovery repair attempt hint json: {json_error}" - ), - } +fn main() -> ExitCode { + match myc::parse_myc_cli_v1_from(std::env::args_os()) { + Ok(_) => { + eprintln!("myc: command execution is unavailable"); + ExitCode::FAILURE + } + Err(error) => { + eprintln!("myc: {error}"); + ExitCode::from(2) } - std::process::exit(1); } } diff --git a/src/paths.rs b/src/paths.rs @@ -3,14 +3,10 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::{ - config::{ - MycConfig, MycIdentityBackend, MycIdentitySourceSpec, config_parse_error, - parse_optional_path_env, - }, + config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec}, 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"; @@ -21,8 +17,6 @@ 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, Copy, PartialEq, Eq)] #[allow(dead_code)] @@ -194,36 +188,10 @@ impl RadrootsRuntimePathSelection { } } - pub fn from_env( - profile_env: &str, - root_env: &str, - default_profile: RadrootsPathProfile, - ) -> Result<Self, String> { - let profile = match std::env::var(profile_env).ok().as_deref() { - None => default_profile, - Some("interactive_user") => RadrootsPathProfile::InteractiveUser, - Some("service_host") => RadrootsPathProfile::ServiceHost, - Some("repo_local") => RadrootsPathProfile::RepoLocal, - Some(value) => return Err(format!("unknown path profile `{value}`")), - }; - let repo_local_root = std::env::var_os(root_env) - .filter(|value| !value.is_empty()) - .map(PathBuf::from); - if profile == RadrootsPathProfile::RepoLocal && repo_local_root.is_none() { - return Err(format!("{root_env} is required for repo_local")); - } - Ok(Self { - profile, - repo_local_root, - }) - } - fn resolve_service_roots( &self, resolver: &RadrootsPathResolver, service: &str, - _profile_env: &str, - _root_env: &str, ) -> Result<RuntimeRoots, String> { Ok(resolver .roots(self.profile, self.repo_local_root.as_deref())? @@ -253,7 +221,6 @@ impl RadrootsRuntimePathPolicyContract { 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, @@ -279,7 +246,6 @@ pub enum MycPathProfile { #[derive(Debug, Clone, PartialEq, Eq)] struct MycResolvedRuntimePaths { - config_env_path: PathBuf, logs_dir: PathBuf, run_dir: PathBuf, state_dir: PathBuf, @@ -342,18 +308,12 @@ impl MycResolvedRuntimePaths { repo_local_root.map(Path::to_path_buf), ); let namespaced = selection - .resolve_service_roots( - resolver, - "myc", - MYC_PATHS_PROFILE_ENV, - MYC_PATHS_REPO_LOCAL_ROOT_ENV, - ) + .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 { - 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), @@ -384,7 +344,6 @@ impl MycPathsConfig { 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, @@ -471,36 +430,6 @@ impl MycPathsConfig { } } -pub(crate) fn process_path_selection() -> Result<(MycPathProfile, Option<PathBuf>), MycError> { - let selection = RadrootsRuntimePathSelection::from_env( - MYC_PATHS_PROFILE_ENV, - MYC_PATHS_REPO_LOCAL_ROOT_ENV, - RadrootsPathProfile::InteractiveUser, - ) - .map_err(|error| MycError::InvalidConfig(error.to_string()))?; - Ok(( - from_radroots_profile(selection.profile), - selection.repo_local_root, - )) -} - -fn from_radroots_profile(profile: RadrootsPathProfile) -> MycPathProfile { - match profile { - RadrootsPathProfile::InteractiveUser => MycPathProfile::InteractiveUser, - RadrootsPathProfile::ServiceHost => MycPathProfile::ServiceHost, - RadrootsPathProfile::RepoLocal => MycPathProfile::RepoLocal, - RadrootsPathProfile::MobileNative => MycPathProfile::InteractiveUser, - } -} - -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, @@ -511,7 +440,6 @@ pub(crate) fn apply_path_defaults( 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); @@ -556,41 +484,3 @@ pub(crate) fn apply_path_defaults( } 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/src/persistence.rs b/src/persistence.rs @@ -221,7 +221,7 @@ impl MycPersistenceImportSelection { && config.persistence.signer_state_backend != MycSignerStateBackend::Sqlite { return Err(MycError::InvalidOperation( - "json-to-sqlite signer-state import requires MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite" + "json-to-sqlite signer-state import requires the sqlite signer-state backend" .to_owned(), )); } @@ -229,7 +229,7 @@ impl MycPersistenceImportSelection { && config.persistence.runtime_audit_backend != MycRuntimeAuditBackend::Sqlite { return Err(MycError::InvalidOperation( - "json-to-sqlite runtime-audit import requires MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite" + "json-to-sqlite runtime-audit import requires the sqlite runtime-audit backend" .to_owned(), )); } diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs @@ -1,1497 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::fs; -use std::net::TcpListener as StdTcpListener; -use std::path::Path; -use std::process::{Command, Output}; -use std::sync::Arc; -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use myc::host_identity::RadrootsIdentity; -use myc::nostr_contract::{ - RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrMetadata, - radroots_nostr_build_application_handler_event, -}; -use nostr::filter::MatchEventOptions; -use nostr::{ClientMessage, Event, Filter, JsonUtil, PublicKey, RelayMessage, SubscriptionId}; -use radroots_nostr_connect::uri::Uri; -use serde_json::Value; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{Mutex, Notify, mpsc, oneshot}; -use tokio::time::timeout; -use tokio_tungstenite::tungstenite::Message; - -type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; - -const RELAY_EVENT_TIMEOUT: Duration = Duration::from_secs(15); - -#[derive(Clone)] -struct RelaySubscription { - connection_id: usize, - subscription_id: SubscriptionId, - filters: Vec<Filter>, -} - -#[derive(Default)] -struct RelayState { - next_connection_id: usize, - senders: HashMap<usize, mpsc::UnboundedSender<Message>>, - subscriptions: Vec<RelaySubscription>, - published_events: Vec<Event>, - publish_outcomes_by_pubkey: HashMap<String, VecDeque<bool>>, -} - -struct TestRelay { - url: String, - state: Arc<Mutex<RelayState>>, - notify: Arc<Notify>, - shutdown_tx: Option<oneshot::Sender<()>>, -} - -impl TestRelay { - async fn spawn() -> TestResult<Self> { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - let url = format!("ws://{addr}"); - let state = Arc::new(Mutex::new(RelayState::default())); - let notify = Arc::new(Notify::new()); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let relay_state = Arc::clone(&state); - let relay_notify = Arc::clone(&notify); - - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut shutdown_rx => break, - accept = listener.accept() => { - let Ok((stream, _)) = accept else { - break; - }; - let state = Arc::clone(&relay_state); - let notify = Arc::clone(&relay_notify); - tokio::spawn(async move { - let _ = handle_relay_connection(stream, state, notify).await; - }); - } - } - } - }); - - Ok(Self { - url, - state, - notify, - shutdown_tx: Some(shutdown_tx), - }) - } - - fn url(&self) -> &str { - self.url.as_str() - } - - async fn queue_publish_outcomes(&self, public_key: PublicKey, outcomes: &[bool]) { - let mut state = self.state.lock().await; - state - .publish_outcomes_by_pubkey - .insert(public_key.to_hex(), outcomes.iter().copied().collect()); - } - - async fn wait_for_published_events_by_author( - &self, - public_key: PublicKey, - expected: usize, - ) -> TestResult<Vec<Event>> { - timeout(RELAY_EVENT_TIMEOUT, async { - loop { - let events = self.published_events_by_author(public_key).await; - if events.len() >= expected { - return events; - } - self.notify.notified().await; - } - }) - .await - .map_err(Into::into) - } - - async fn published_events_by_author(&self, public_key: PublicKey) -> Vec<Event> { - self.state - .lock() - .await - .published_events - .iter() - .filter(|event| event.pubkey == public_key) - .cloned() - .collect() - } -} - -impl Drop for TestRelay { - fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); - } - } -} - -async fn handle_relay_connection( - stream: TcpStream, - state: Arc<Mutex<RelayState>>, - notify: Arc<Notify>, -) -> TestResult<()> { - let websocket = tokio_tungstenite::accept_async(stream).await?; - let (mut writer, mut reader) = websocket.split(); - let (tx, mut rx) = mpsc::unbounded_channel::<Message>(); - let connection_id = { - let mut state = state.lock().await; - let connection_id = state.next_connection_id; - state.next_connection_id += 1; - state.senders.insert(connection_id, tx); - notify.notify_waiters(); - connection_id - }; - - let writer_task = tokio::spawn(async move { - while let Some(message) = rx.recv().await { - if writer.send(message).await.is_err() { - break; - } - } - }); - - while let Some(message) = reader.next().await { - let message = message?; - let Message::Text(text) = message else { - continue; - }; - let client_message = ClientMessage::from_json(text.as_str())?; - handle_client_message(connection_id, client_message, &state, &notify).await?; - } - - writer_task.abort(); - let mut state = state.lock().await; - state.senders.remove(&connection_id); - state - .subscriptions - .retain(|subscription| subscription.connection_id != connection_id); - notify.notify_waiters(); - Ok(()) -} - -async fn handle_client_message( - connection_id: usize, - client_message: ClientMessage<'_>, - state: &Arc<Mutex<RelayState>>, - notify: &Arc<Notify>, -) -> TestResult<()> { - match client_message { - ClientMessage::Req { - subscription_id, - filters, - } => { - let (sender, matching_events) = { - let mut state = state.lock().await; - let matching_events = state - .published_events - .iter() - .filter(|event| { - filters - .iter() - .any(|filter| filter.match_event(event, MatchEventOptions::new())) - }) - .cloned() - .collect::<Vec<_>>(); - state.subscriptions.push(RelaySubscription { - connection_id, - subscription_id: subscription_id.as_ref().clone(), - filters: filters - .into_iter() - .map(|filter| filter.into_owned()) - .collect(), - }); - notify.notify_waiters(); - (state.senders.get(&connection_id).cloned(), matching_events) - }; - if let Some(sender) = sender { - for event in matching_events { - let message = - RelayMessage::event(subscription_id.as_ref().clone(), event).as_json(); - let _ = sender.send(Message::Text(message.into())); - } - let eose = RelayMessage::eose(subscription_id.as_ref().clone()).as_json(); - let _ = sender.send(Message::Text(eose.into())); - } - } - ClientMessage::Close(subscription_id) => { - let mut state = state.lock().await; - state.subscriptions.retain(|subscription| { - subscription.connection_id != connection_id - || subscription.subscription_id != *subscription_id - }); - notify.notify_waiters(); - } - ClientMessage::Event(event) => { - let event = event.into_owned(); - let (ok_message, subscriber_messages) = - accept_published_event(connection_id, event, state, notify).await?; - if let Some((sender, message)) = ok_message { - let _ = sender.send(message); - } - for (sender, message) in subscriber_messages { - let _ = sender.send(message); - } - } - _ => {} - } - - Ok(()) -} - -async fn accept_published_event( - connection_id: usize, - event: Event, - state: &Arc<Mutex<RelayState>>, - notify: &Arc<Notify>, -) -> TestResult<( - Option<(mpsc::UnboundedSender<Message>, Message)>, - Vec<(mpsc::UnboundedSender<Message>, Message)>, -)> { - let event_id = event.id; - let event_pubkey_hex = event.pubkey.to_hex(); - let mut subscriber_messages = Vec::new(); - let mut ok_message = None; - - { - let mut state = state.lock().await; - let publish_status = state - .publish_outcomes_by_pubkey - .get_mut(&event_pubkey_hex) - .and_then(|outcomes| outcomes.pop_front()) - .unwrap_or(true); - - if let Some(sender) = state.senders.get(&connection_id).cloned() { - let message = if publish_status { - RelayMessage::ok(event_id, true, "").as_json() - } else { - RelayMessage::ok(event_id, false, "blocked by test relay").as_json() - }; - ok_message = Some((sender, Message::Text(message.into()))); - } - - if publish_status { - state.published_events.push(event.clone()); - for subscription in &state.subscriptions { - if subscription - .filters - .iter() - .any(|filter| filter.match_event(&event, MatchEventOptions::new())) - && let Some(sender) = state.senders.get(&subscription.connection_id).cloned() - { - let message = - RelayMessage::event(subscription.subscription_id.clone(), event.clone()) - .as_json(); - subscriber_messages.push((sender, Message::Text(message.into()))); - } - } - } - notify.notify_waiters(); - } - - Ok((ok_message, subscriber_messages)) -} - -fn write_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - myc::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); -} - -fn write_env_file( - path: &Path, - state_dir: &Path, - signer_identity_path: &Path, - user_identity_path: &Path, - app_identity_path: &Path, - relay_urls: &[&str], -) { - let relay_list = relay_urls.join(","); - let env_file = format!( - r#"MYC_SERVICE_INSTANCE_NAME=myc -MYC_LOGGING_FILTER=info,myc=info -MYC_PATHS_STATE_DIR={state_dir} -MYC_IDENTITY_SIGNER_PATH={signer_identity_path} -MYC_IDENTITY_USER_PATH={user_identity_path} -MYC_AUDIT_DEFAULT_READ_LIMIT=200 -MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=262144 -MYC_AUDIT_MAX_ARCHIVED_FILES=8 -MYC_DISCOVERY_ENABLED=true -MYC_DISCOVERY_DOMAIN=signer.example.com -MYC_DISCOVERY_HANDLER_IDENTIFIER=myc -MYC_IDENTITY_DISCOVERY_APP_PATH={app_identity_path} -MYC_DISCOVERY_PUBLIC_RELAY_URLS={relay_list} -MYC_DISCOVERY_PUBLISH_RELAY_URLS={relay_list} -MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://signer.example.com/connect?uri=<nostrconnect> -MYC_DISCOVERY_NIP05_OUTPUT_PATH={nip05_output_path} -MYC_DISCOVERY_METADATA_NAME=myc -MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza -MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer -MYC_DISCOVERY_METADATA_WEBSITE=https://signer.example.com -MYC_DISCOVERY_METADATA_PICTURE=https://signer.example.com/logo.png -MYC_POLICY_CONNECTION_APPROVAL=explicit_user -MYC_TRANSPORT_ENABLED=false -MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 -MYC_TRANSPORT_RELAY_URLS= -"#, - state_dir = state_dir.display(), - signer_identity_path = signer_identity_path.display(), - user_identity_path = user_identity_path.display(), - app_identity_path = app_identity_path.display(), - relay_list = relay_list, - nip05_output_path = state_dir.join("public/.well-known/nostr.json").display(), - ); - fs::write(path, env_file).expect("write env file"); -} - -fn run_myc(env_path: &Path, args: &[&str]) -> TestResult<Output> { - Ok(Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(env_path) - .args(args) - .output()?) -} - -fn extract_discovery_attempt_id(stderr: &str) -> Option<&str> { - stderr - .lines() - .find_map(|line| line.strip_prefix("myc: discovery repair attempt id: ")) -} - -fn extract_discovery_attempt_hint(stderr: &str) -> Option<Value> { - stderr.lines().find_map(|line| { - line.strip_prefix("myc: discovery repair attempt json: ") - .and_then(|json| serde_json::from_str(json).ok()) - }) -} - -fn unavailable_relay_url() -> TestResult<String> { - let listener = StdTcpListener::bind("127.0.0.1:0")?; - let addr = listener.local_addr()?; - drop(listener); - Ok(format!("ws://{addr}")) -} - -async fn publish_handler_event( - relay_url: &str, - identity: &RadrootsIdentity, - spec: &RadrootsNostrApplicationHandlerSpec, -) -> TestResult<Event> { - let event = radroots_nostr_build_application_handler_event(spec)? - .sign_with_keys(identity.keys()) - .map_err(|error| format!("failed to sign handler event: {error}"))?; - let client = RadrootsNostrClient::from_identity(identity); - let _ = client.add_relay(relay_url).await?; - client.connect().await; - client.wait_for_connection(Duration::from_secs(1)).await; - let output = client.send_event(&event).await?; - assert!( - !output.success.is_empty(), - "handler event publish did not succeed: {:?}", - output.failed - ); - Ok(event) -} - -#[test] -fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> { - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let bundle_dir = temp.path().join("bundle"); - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - write_identity( - &app_identity_path, - "3333333333333333333333333333333333333333333333333333333333333333", - ); - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &["wss://relay.example.com"], - ); - - let export = run_myc( - &env_path, - &[ - "discovery", - "export-bundle", - "--out", - bundle_dir.to_str().unwrap(), - ], - )?; - - assert!( - export.status.success(), - "export-bundle failed: {}", - String::from_utf8_lossy(&export.stderr) - ); - let export_output: Value = serde_json::from_slice(&export.stdout)?; - assert_eq!(export_output["manifest"]["domain"], "signer.example.com"); - assert!(bundle_dir.join("bundle.json").exists()); - assert!(bundle_dir.join(".well-known/nostr.json").exists()); - assert!(bundle_dir.join("nip89-handler.json").exists()); - - let verify = run_myc( - &env_path, - &[ - "discovery", - "verify-bundle", - "--dir", - bundle_dir.to_str().unwrap(), - ], - )?; - - assert!( - verify.status.success(), - "verify-bundle failed: {}", - String::from_utf8_lossy(&verify.stderr) - ); - let verify_output: Value = serde_json::from_slice(&verify.stdout)?; - assert_eq!(verify_output["manifest"]["domain"], "signer.example.com"); - assert_eq!( - verify_output["manifest"]["nip05_relative_path"], - ".well-known/nostr.json" - ); - assert_eq!( - verify_output["manifest"]["nip89_relative_path"], - "nip89-handler.json" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let app_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - )?; - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[relay.url()], - ); - - let inspect_missing = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; - assert!( - inspect_missing.status.success(), - "inspect-live-nip89 failed: {}", - String::from_utf8_lossy(&inspect_missing.stderr) - ); - let inspect_missing_output: Value = serde_json::from_slice(&inspect_missing.stdout)?; - assert_eq!( - inspect_missing_output["live_groups"] - .as_array() - .unwrap() - .len(), - 0 - ); - assert_eq!( - inspect_missing_output["relay_states"] - .as_array() - .unwrap() - .len(), - 1 - ); - - let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - refresh.status.success(), - "refresh-nip89 failed: {}", - String::from_utf8_lossy(&refresh.stderr) - ); - let refresh_output: Value = serde_json::from_slice(&refresh.stdout)?; - assert_eq!(refresh_output["status"], "missing"); - assert!(refresh_output["published"].is_object()); - - relay - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - let inspect_live = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; - assert!( - inspect_live.status.success(), - "inspect-live-nip89 after refresh failed: {}", - String::from_utf8_lossy(&inspect_live.stderr) - ); - let inspect_live_output: Value = serde_json::from_slice(&inspect_live.stdout)?; - assert_eq!( - inspect_live_output["live_groups"].as_array().unwrap().len(), - 1 - ); - assert_eq!( - inspect_live_output["live_groups"][0]["source_relays"] - .as_array() - .unwrap() - .len(), - 1 - ); - - let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; - assert!( - diff.status.success(), - "diff-live-nip89 failed: {}", - String::from_utf8_lossy(&diff.stderr) - ); - let diff_output: Value = serde_json::from_slice(&diff.stdout)?; - assert_eq!(diff_output["status"], "matched"); - assert_eq!(diff_output["live_groups"].as_array().unwrap().len(), 1); - assert_eq!( - diff_output["relay_summary"]["matched_relays"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!( - diff_output["relay_states"][0]["fetch_status"], - Value::String("available".to_owned()) - ); - assert_eq!( - diff_output["relay_states"][0]["live_status"], - Value::String("matched".to_owned()) - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let app_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - )?; - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[relay.url()], - ); - - let first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://relay-a.example.com".to_owned()]); - publish_handler_event(relay.url(), &app_identity, &first_spec).await?; - - let mut second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://relay-b.example.com".to_owned()]); - let metadata = RadrootsNostrMetadata { - name: Some("conflict".to_owned()), - ..RadrootsNostrMetadata::default() - }; - second_spec = second_spec.with_metadata(metadata); - publish_handler_event(relay.url(), &app_identity, &second_spec).await?; - - relay - .wait_for_published_events_by_author(app_identity.public_key(), 2) - .await?; - - let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; - assert!( - diff.status.success(), - "diff-live-nip89 failed: {}", - String::from_utf8_lossy(&diff.stderr) - ); - let diff_output: Value = serde_json::from_slice(&diff.stdout)?; - assert_eq!(diff_output["status"], "conflicted"); - assert_eq!(diff_output["live_groups"].as_array().unwrap().len(), 2); - assert_eq!( - diff_output["relay_summary"]["conflicted_relays"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert!( - diff_output["relay_summary"]["unavailable_relays"] - .as_array() - .unwrap() - .is_empty() - ); - - let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - !refresh.status.success(), - "refresh-nip89 unexpectedly succeeded: {}", - String::from_utf8_lossy(&refresh.stdout) - ); - assert!( - String::from_utf8_lossy(&refresh.stderr).contains("conflicted"), - "unexpected refresh stderr: {}", - String::from_utf8_lossy(&refresh.stderr) - ); - let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); - let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); - let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); - assert_eq!( - attempt_hint["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - assert_eq!( - attempt_hint["inspect_args"], - Value::Array(vec![ - Value::String("audit".to_owned()), - Value::String("discovery-repair-attempt".to_owned()), - Value::String("--attempt-id".to_owned()), - Value::String(attempt_id.to_owned()), - ]) - ); - let attempt = run_myc( - &env_path, - &[ - "audit", - "discovery-repair-attempt", - "--attempt-id", - attempt_id, - ], - )?; - assert!( - attempt.status.success(), - "discovery-repair-attempt failed: {}", - String::from_utf8_lossy(&attempt.stderr) - ); - let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; - assert_eq!( - attempt_output["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - assert_eq!( - attempt_output["refresh_outcome"], - Value::String("conflicted".to_owned()) - ); - assert_eq!( - attempt_output["planned_repair_relays"], - Value::Array(vec![Value::String(relay.url().to_owned())]) - ); - assert_eq!( - attempt_output["blocked_relays"], - Value::Array(vec![Value::String(relay.url().to_owned())]) - ); - assert_eq!( - attempt_output["blocked_reason"], - Value::String("conflicted_relays".to_owned()) - ); - assert_eq!( - attempt_output["remaining_repair_relays"], - Value::Array(vec![Value::String(relay.url().to_owned())]) - ); - - let forced_refresh = run_myc(&env_path, &["discovery", "refresh-nip89", "--force"])?; - assert!( - forced_refresh.status.success(), - "refresh-nip89 --force failed: {}", - String::from_utf8_lossy(&forced_refresh.stderr) - ); - let forced_refresh_output: Value = serde_json::from_slice(&forced_refresh.stdout)?; - assert_eq!(forced_refresh_output["status"], "conflicted"); - assert!(forced_refresh_output["published"].is_object()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let app_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - )?; - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[relay_a.url(), relay_b.url()], - ); - - relay_a - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - relay_b - .queue_publish_outcomes(app_identity.public_key(), &[false]) - .await; - - let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - refresh.status.success(), - "refresh-nip89 failed: {}", - String::from_utf8_lossy(&refresh.stderr) - ); - let refresh_output: Value = serde_json::from_slice(&refresh.stdout)?; - assert_eq!(refresh_output["status"], "missing"); - assert_eq!(refresh_output["repair_summary"]["repaired"], 1); - assert_eq!(refresh_output["repair_summary"]["failed"], 1); - assert_eq!(refresh_output["repair_summary"]["unchanged"], 0); - assert_eq!(refresh_output["repair_summary"]["skipped"], 0); - assert_eq!( - refresh_output["remaining_repair_relays"], - Value::Array(vec![Value::String(relay_b.url().to_owned())]) - ); - assert_eq!( - refresh_output["published"]["acknowledged_relay_count"], - Value::from(1_u64) - ); - - relay_a - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - assert_eq!( - relay_b - .published_events_by_author(app_identity.public_key()) - .await - .len(), - 0 - ); - - let audit_summary = run_myc(&env_path, &["audit", "summary", "--scope", "operation"])?; - assert!( - audit_summary.status.success(), - "audit summary failed: {}", - String::from_utf8_lossy(&audit_summary.stderr) - ); - let audit_summary_output: Value = serde_json::from_slice(&audit_summary.stdout)?; - assert_eq!( - audit_summary_output["runtime_aggregate_publish_rejection_count"], - Value::from(0_u64) - ); - assert_eq!( - audit_summary_output["runtime_repair_success_count"], - Value::from(1_u64) - ); - assert_eq!( - audit_summary_output["runtime_repair_rejection_count"], - Value::from(1_u64) - ); - assert_eq!( - audit_summary_output["runtime_operation_by_kind"]["discovery_handler_publish"]["succeeded"], - Value::from(1_u64) - ); - assert_eq!( - audit_summary_output["runtime_operation_by_kind"]["discovery_handler_repair"]["rejected"], - Value::from(1_u64) - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> TestResult<()> { - let relay = TestRelay::spawn().await?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let app_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - )?; - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[relay.url()], - ); - - relay - .queue_publish_outcomes(app_identity.public_key(), &[false]) - .await; - - let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - !refresh.status.success(), - "refresh-nip89 unexpectedly succeeded: {}", - String::from_utf8_lossy(&refresh.stdout) - ); - let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); - assert!( - refresh_stderr.contains("Nostr publish failed"), - "unexpected refresh stderr: {refresh_stderr}" - ); - let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); - let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); - assert_eq!( - attempt_hint["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - - let attempt = run_myc( - &env_path, - &[ - "audit", - "discovery-repair-attempt", - "--attempt-id", - attempt_id, - ], - )?; - assert!( - attempt.status.success(), - "discovery-repair-attempt failed: {}", - String::from_utf8_lossy(&attempt.stderr) - ); - let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; - assert_eq!( - attempt_output["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - assert_eq!( - attempt_output["refresh_outcome"], - Value::String("rejected".to_owned()) - ); - assert_eq!( - attempt_output["aggregate_publish_outcome"], - Value::String("rejected".to_owned()) - ); - assert_eq!( - attempt_output["repair_summary"]["failed"], - Value::from(1_u64) - ); - assert_eq!( - attempt_output["remaining_repair_relays"], - Value::Array(vec![Value::String(relay.url().to_owned())]) - ); - assert_eq!( - attempt_output["planned_repair_relays"], - Value::Array(vec![Value::String(relay.url().to_owned())]) - ); - assert_eq!(attempt_output["blocked_relays"], Value::Array(vec![])); - assert!(attempt_output["blocked_reason"].is_null()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let app_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - )?; - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[relay_a.url(), relay_b.url()], - ); - - relay_a - .queue_publish_outcomes(app_identity.public_key(), &[true]) - .await; - relay_b - .queue_publish_outcomes(app_identity.public_key(), &[false, true]) - .await; - - let first_refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - first_refresh.status.success(), - "first refresh-nip89 failed: {}", - String::from_utf8_lossy(&first_refresh.stderr) - ); - let first_refresh_output: Value = serde_json::from_slice(&first_refresh.stdout)?; - let first_attempt_id = first_refresh_output["attempt_id"] - .as_str() - .expect("first attempt id") - .to_owned(); - assert_eq!(first_refresh_output["repair_summary"]["repaired"], 1); - assert_eq!(first_refresh_output["repair_summary"]["failed"], 1); - assert_eq!( - first_refresh_output["remaining_repair_relays"], - Value::Array(vec![Value::String(relay_b.url().to_owned())]) - ); - - relay_a - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - let second_refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - second_refresh.status.success(), - "second refresh-nip89 failed: {}", - String::from_utf8_lossy(&second_refresh.stderr) - ); - let second_refresh_output: Value = serde_json::from_slice(&second_refresh.stdout)?; - let second_attempt_id = second_refresh_output["attempt_id"] - .as_str() - .expect("second attempt id") - .to_owned(); - assert_ne!(first_attempt_id, second_attempt_id); - assert_eq!(second_refresh_output["repair_summary"]["repaired"], 1); - assert_eq!(second_refresh_output["repair_summary"]["failed"], 0); - assert_eq!(second_refresh_output["repair_summary"]["unchanged"], 1); - assert_eq!( - second_refresh_output["remaining_repair_relays"], - Value::Array(vec![]) - ); - - let latest_attempt = run_myc(&env_path, &["audit", "latest-discovery-repair"])?; - assert!( - latest_attempt.status.success(), - "latest-discovery-repair failed: {}", - String::from_utf8_lossy(&latest_attempt.stderr) - ); - let latest_attempt_output: Value = serde_json::from_slice(&latest_attempt.stdout)?; - assert_eq!( - latest_attempt_output["attempt_id"], - Value::String(second_attempt_id.clone()) - ); - assert_eq!( - latest_attempt_output["compare_outcome"], - Value::String("matched".to_owned()) - ); - assert_eq!( - latest_attempt_output["refresh_outcome"], - Value::String("succeeded".to_owned()) - ); - assert_eq!(latest_attempt_output["repair_summary"]["repaired"], 1); - assert_eq!(latest_attempt_output["repair_summary"]["failed"], 0); - assert_eq!(latest_attempt_output["repair_summary"]["unchanged"], 1); - assert_eq!( - latest_attempt_output["remaining_repair_relays"], - Value::Array(vec![]) - ); - - let first_attempt_summary = run_myc( - &env_path, - &[ - "audit", - "discovery-repair-attempt", - "--attempt-id", - first_attempt_id.as_str(), - ], - )?; - assert!( - first_attempt_summary.status.success(), - "discovery-repair-attempt summary failed: {}", - String::from_utf8_lossy(&first_attempt_summary.stderr) - ); - let first_attempt_summary_output: Value = - serde_json::from_slice(&first_attempt_summary.stdout)?; - assert_eq!( - first_attempt_summary_output["attempt_id"], - Value::String(first_attempt_id.clone()) - ); - assert_eq!( - first_attempt_summary_output["refresh_outcome"], - Value::String("succeeded".to_owned()) - ); - assert_eq!( - first_attempt_summary_output["repair_summary"]["repaired"], - 1 - ); - assert_eq!(first_attempt_summary_output["repair_summary"]["failed"], 1); - assert_eq!( - first_attempt_summary_output["failed_relays"], - Value::Array(vec![Value::String(relay_b.url().to_owned())]) - ); - assert_eq!( - first_attempt_summary_output["remaining_repair_relays"], - Value::Array(vec![Value::String(relay_b.url().to_owned())]) - ); - - let first_attempt_records = run_myc( - &env_path, - &[ - "audit", - "discovery-repair-attempt", - "--attempt-id", - first_attempt_id.as_str(), - "--view", - "records", - ], - )?; - assert!( - first_attempt_records.status.success(), - "discovery-repair-attempt records failed: {}", - String::from_utf8_lossy(&first_attempt_records.stderr) - ); - let first_attempt_records_output: Value = - serde_json::from_slice(&first_attempt_records.stdout)?; - let record_attempt_ids = first_attempt_records_output["runtime_operation_audit"] - .as_array() - .expect("attempt records") - .iter() - .map(|record| record["attempt_id"].as_str().expect("record attempt id")) - .collect::<Vec<_>>(); - assert!(!record_attempt_ids.is_empty()); - assert!( - record_attempt_ids - .iter() - .all(|attempt_id| *attempt_id == first_attempt_id) - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResult<()> { - let relay_a = TestRelay::spawn().await?; - let relay_b = TestRelay::spawn().await?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let app_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - )?; - let signer_identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - )?; - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[relay_a.url(), relay_b.url()], - ); - - let mut matched_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec![relay_a.url().to_owned(), relay_b.url().to_owned()]); - let mut bunker_query = url::form_urlencoded::Serializer::new(String::new()); - bunker_query.append_pair("relay", relay_a.url()); - bunker_query.append_pair("relay", relay_b.url()); - let bunker_uri = Uri::parse(&format!( - "bunker://{}?{}", - signer_identity.final_public_key(), - bunker_query.finish() - ))? - .to_string(); - let encoded_bunker_uri: String = - url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect(); - matched_spec = matched_spec.with_nostr_connect_url(format!( - "https://signer.example.com/connect?uri={encoded_bunker_uri}" - )); - let matched_metadata = RadrootsNostrMetadata { - name: Some("myc".to_owned()), - display_name: Some("Mycorrhiza".to_owned()), - about: Some("NIP-46 signer".to_owned()), - website: Some("https://signer.example.com".to_owned()), - picture: Some("https://signer.example.com/logo.png".to_owned()), - ..RadrootsNostrMetadata::default() - }; - matched_spec = matched_spec.with_metadata(matched_metadata); - publish_handler_event(relay_a.url(), &app_identity, &matched_spec).await?; - - let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]) - .with_identifier("myc".to_owned()) - .with_relays(vec!["wss://stale.example.com".to_owned()]); - let drifted_metadata = RadrootsNostrMetadata { - name: Some("stale".to_owned()), - ..RadrootsNostrMetadata::default() - }; - drifted_spec = drifted_spec.with_metadata(drifted_metadata); - publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?; - - relay_a - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - relay_b - .wait_for_published_events_by_author(app_identity.public_key(), 1) - .await?; - - let inspect = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; - assert!( - inspect.status.success(), - "inspect-live-nip89 failed: {}", - String::from_utf8_lossy(&inspect.stderr) - ); - let inspect_output: Value = serde_json::from_slice(&inspect.stdout)?; - assert_eq!(inspect_output["live_groups"].as_array().unwrap().len(), 2); - assert_eq!(inspect_output["relay_states"].as_array().unwrap().len(), 2); - let group_relays = inspect_output["live_groups"] - .as_array() - .unwrap() - .iter() - .map(|group| { - group["source_relays"] - .as_array() - .unwrap() - .iter() - .map(|relay| relay.as_str().unwrap().to_owned()) - .collect::<Vec<_>>() - }) - .collect::<Vec<_>>(); - assert!( - group_relays - .iter() - .any(|relays| relays == &vec![relay_a.url().to_owned()]) - ); - assert!( - group_relays - .iter() - .any(|relays| relays == &vec![relay_b.url().to_owned()]) - ); - - let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; - assert!( - diff.status.success(), - "diff-live-nip89 failed: {}", - String::from_utf8_lossy(&diff.stderr) - ); - let diff_output: Value = serde_json::from_slice(&diff.stdout)?; - assert_eq!(diff_output["status"], "conflicted"); - assert_eq!( - diff_output["relay_summary"]["matched_relays"], - Value::Array(vec![Value::String(relay_a.url().to_owned())]) - ); - assert_eq!( - diff_output["relay_summary"]["drifted_relays"], - Value::Array(vec![Value::String(relay_b.url().to_owned())]) - ); - assert_eq!( - diff_output["relay_summary"]["conflicted_relays"], - Value::Array(vec![]) - ); - assert_eq!(diff_output["relay_states"].as_array().unwrap().len(), 2); - for relay_state in diff_output["relay_states"].as_array().unwrap() { - assert_eq!( - relay_state["fetch_status"], - Value::String("available".to_owned()) - ); - assert!(relay_state["live_status"].is_string()); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_the_cli() --> TestResult<()> { - let relay = TestRelay::spawn().await?; - let unavailable_relay = unavailable_relay_url()?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - let app_identity = RadrootsIdentity::from_secret_key_str( - "3333333333333333333333333333333333333333333333333333333333333333", - )?; - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[relay.url(), unavailable_relay.as_str()], - ); - - let inspect = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; - assert!( - inspect.status.success(), - "inspect-live-nip89 failed: {}", - String::from_utf8_lossy(&inspect.stderr) - ); - let inspect_output: Value = serde_json::from_slice(&inspect.stdout)?; - assert_eq!(inspect_output["live_groups"].as_array().unwrap().len(), 0); - assert_eq!(inspect_output["relay_states"].as_array().unwrap().len(), 2); - assert!( - inspect_output["relay_states"] - .as_array() - .unwrap() - .iter() - .any(|relay_state| { - relay_state["relay_url"] == Value::String(unavailable_relay.clone()) - && relay_state["fetch_status"] == Value::String("unavailable".to_owned()) - && relay_state["live_status"].is_null() - && relay_state["fetch_error"].is_string() - }) - ); - - let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - !refresh.status.success(), - "refresh-nip89 unexpectedly succeeded: {}", - String::from_utf8_lossy(&refresh.stdout) - ); - assert!( - String::from_utf8_lossy(&refresh.stderr).contains("unavailable"), - "unexpected refresh stderr: {}", - String::from_utf8_lossy(&refresh.stderr) - ); - let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); - let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); - let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); - assert_eq!( - attempt_hint["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - let attempt = run_myc( - &env_path, - &[ - "audit", - "discovery-repair-attempt", - "--attempt-id", - attempt_id, - ], - )?; - assert!( - attempt.status.success(), - "discovery-repair-attempt failed: {}", - String::from_utf8_lossy(&attempt.stderr) - ); - let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; - assert_eq!( - attempt_output["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - assert_eq!( - attempt_output["refresh_outcome"], - Value::String("unavailable".to_owned()) - ); - assert_eq!( - attempt_output["planned_repair_relays"], - Value::Array(vec![Value::String(relay.url().to_owned())]) - ); - assert_eq!( - attempt_output["blocked_relays"], - Value::Array(vec![Value::String(unavailable_relay.clone())]) - ); - assert_eq!( - attempt_output["blocked_reason"], - Value::String("unavailable_relays".to_owned()) - ); - assert_eq!( - attempt_output["remaining_repair_relays"], - Value::Array(vec![Value::String(relay.url().to_owned())]) - ); - - let forced_refresh = run_myc(&env_path, &["discovery", "refresh-nip89", "--force"])?; - assert!( - forced_refresh.status.success(), - "refresh-nip89 --force failed: {}", - String::from_utf8_lossy(&forced_refresh.stderr) - ); - let forced_refresh_output: Value = serde_json::from_slice(&forced_refresh.stdout)?; - assert_eq!(forced_refresh_output["status"], "missing"); - assert_eq!( - forced_refresh_output["relay_summary"]["unavailable_relays"], - Value::Array(vec![Value::String(unavailable_relay.clone())]) - ); - assert!(forced_refresh_output["published"].is_object()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavailable() --> TestResult<()> { - let unavailable_relay = unavailable_relay_url()?; - let temp = tempfile::tempdir()?; - let env_path = temp.path().join(".env"); - let state_dir = temp.path().join("state"); - let signer_identity_path = temp.path().join("signer.json"); - let user_identity_path = temp.path().join("user.json"); - let app_identity_path = temp.path().join("app.json"); - - write_identity( - &signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - write_identity( - &app_identity_path, - "3333333333333333333333333333333333333333333333333333333333333333", - ); - write_env_file( - &env_path, - &state_dir, - &signer_identity_path, - &user_identity_path, - &app_identity_path, - &[unavailable_relay.as_str()], - ); - - let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; - assert!( - !refresh.status.success(), - "refresh-nip89 unexpectedly succeeded: {}", - String::from_utf8_lossy(&refresh.stdout) - ); - let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); - assert!( - refresh_stderr.contains("failed to fetch discovery state from all configured relays"), - "unexpected refresh stderr: {refresh_stderr}" - ); - let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); - let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); - assert_eq!( - attempt_hint["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - - let attempt = run_myc( - &env_path, - &[ - "audit", - "discovery-repair-attempt", - "--attempt-id", - attempt_id, - ], - )?; - assert!( - attempt.status.success(), - "discovery-repair-attempt failed: {}", - String::from_utf8_lossy(&attempt.stderr) - ); - let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; - assert_eq!( - attempt_output["attempt_id"], - Value::String(attempt_id.to_owned()) - ); - assert_eq!( - attempt_output["refresh_outcome"], - Value::String("unavailable".to_owned()) - ); - assert_eq!( - attempt_output["planned_repair_relays"], - Value::Array(vec![]) - ); - assert_eq!( - attempt_output["blocked_relays"], - Value::Array(vec![Value::String(unavailable_relay)]) - ); - assert_eq!( - attempt_output["blocked_reason"], - Value::String("all_relays_unavailable".to_owned()) - ); - assert_eq!( - attempt_output["remaining_repair_relays"], - Value::Array(vec![]) - ); - - Ok(()) -} diff --git a/tests/logging_run.rs b/tests/logging_run.rs @@ -1,112 +0,0 @@ -use myc::host_identity::RadrootsIdentity; -use std::path::Path; -use std::process::{Child, Command, Stdio}; -use std::thread; -use std::time::{Duration, Instant}; - -fn write_test_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); - myc::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); -} - -fn wait_for_log_contents( - path: &Path, - timeout: Duration, - expected_substrings: &[&str], -) -> Result<String, String> { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - match std::fs::read_to_string(path) { - Ok(contents) - if !contents.trim().is_empty() - && expected_substrings - .iter() - .all(|substring| contents.contains(substring)) => - { - return Ok(contents); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(format!("failed to read log file: {error}")), - } - thread::sleep(Duration::from_millis(100)); - } - Err(format!( - "timed out waiting for non-empty log file at {}", - path.display() - )) -} - -fn kill_child(child: &mut Child) { - let _ = child.kill(); - let _ = child.wait(); -} - -#[test] -fn myc_run_writes_non_empty_bounded_log_file() { - let temp = tempfile::tempdir().expect("tempdir"); - let state_dir = temp.path().join("state"); - let logs_dir = temp.path().join("logs"); - let signer_path = temp.path().join("signer.json"); - let user_path = temp.path().join("user.json"); - let env_path = temp.path().join("myc.env"); - - write_test_identity( - signer_path.as_path(), - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - user_path.as_path(), - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - std::fs::write( - &env_path, - format!( - "MYC_SERVICE_INSTANCE_NAME=myc-test\n\ -MYC_LOGGING_FILTER=info,myc=info\n\ -MYC_LOGGING_OUTPUT_DIR={}\n\ -MYC_LOGGING_STDOUT=false\n\ -MYC_PATHS_STATE_DIR={}\n\ -MYC_IDENTITY_SIGNER_PATH={}\n\ -MYC_IDENTITY_USER_PATH={}\n\ -MYC_DISCOVERY_ENABLED=false\n\ -MYC_TRANSPORT_ENABLED=false\n\ -MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10\n", - logs_dir.display(), - state_dir.display(), - signer_path.display(), - user_path.display(), - ), - ) - .expect("write env"); - - let expected_log_path = logs_dir.join("myc.log"); - - let mut child = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("run") - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn myc"); - - let contents = match wait_for_log_contents( - expected_log_path.as_path(), - Duration::from_secs(5), - &["logging initialized", "myc runtime bootstrapped"], - ) { - Ok(contents) => contents, - Err(error) => { - kill_child(&mut child); - panic!("{error}"); - } - }; - - kill_child(&mut child); - - assert!(expected_log_path.exists()); - assert!(contents.contains("logging initialized")); - assert!(contents.contains("myc runtime bootstrapped")); -} diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs @@ -1,389 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Command; - -use myc::host_identity::RadrootsIdentity; -use myc::nostr_contract::{RadrootsNostrGenericEventBuilder, RadrootsNostrKind}; -use myc::{ - MYC_SIGNER_STATUS_CONTRACT_VERSION, MycActiveIdentity, MycDeliveryOutboxKind, - MycDeliveryOutboxRecord, MycOperationAuditKind, MycOperationAuditOutcome, - MycOperationAuditRecord, MycRuntime, -}; -use serde_json::{Value, json}; - -fn write_test_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); - myc::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); -} - -fn write_env_file(temp: &tempfile::TempDir) -> std::path::PathBuf { - let state_dir = temp.path().join("state"); - let signer_path = temp.path().join("signer.json"); - let user_path = temp.path().join("user.json"); - let env_path = temp.path().join("myc.env"); - - write_test_identity( - signer_path.as_path(), - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_test_identity( - user_path.as_path(), - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - std::fs::write( - &env_path, - format!( - "MYC_SERVICE_INSTANCE_NAME=myc-test\n\ -MYC_LOGGING_FILTER=info,myc=info\n\ -MYC_LOGGING_STDOUT=false\n\ -MYC_PATHS_STATE_DIR={}\n\ -MYC_IDENTITY_SIGNER_PATH={}\n\ -MYC_IDENTITY_USER_PATH={}\n\ -MYC_DISCOVERY_ENABLED=false\n\ -MYC_TRANSPORT_ENABLED=false\n\ -MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=1\n", - state_dir.display(), - signer_path.display(), - user_path.display(), - ), - ) - .expect("write env"); - - env_path -} - -fn signed_event(identity: &MycActiveIdentity) -> nostr::Event { - identity - .sign_protocol_event_builder( - RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(24133), "operability"), - "operability test event", - ) - .expect("sign event") -} - -#[test] -fn status_signer_command_emits_local_contract_json() { - let temp = tempfile::tempdir().expect("tempdir"); - let env_path = write_env_file(&temp); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("status") - .arg("--view") - .arg("signer") - .output() - .expect("run myc signer status"); - - assert!(output.status.success()); - let value: Value = serde_json::from_slice(&output.stdout).expect("signer status json"); - assert_eq!( - value["status_contract_version"], - MYC_SIGNER_STATUS_CONTRACT_VERSION - ); - assert_eq!(value["status"], "healthy"); - assert_eq!(value["ready"], true); - assert_eq!( - value["runtime_contract"]["active_profile"], - "interactive_user" - ); - assert_eq!(value["custody"]["signer"]["resolved"], true); - assert_eq!(value["custody"]["user"]["resolved"], true); - assert_eq!( - value["signer_backend"]["local_signer"]["availability"], - "SecretBacked" - ); - assert_eq!(value["signer_backend"]["remote_session_count"], 0); - assert!(value.get("transport").is_none()); - assert!(value.get("discovery").is_none()); - assert!(value.get("persistence").is_none()); - assert!(value.get("delivery_outbox").is_none()); -} - -#[test] -fn status_ignores_retired_process_env_config_names() { - let temp = tempfile::tempdir().expect("tempdir"); - let env_path = write_env_file(&temp); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .env( - "MYC_PATHS_SIGNER_IDENTITY_PATH", - temp.path().join("missing-signer.json"), - ) - .env( - "MYC_PATHS_USER_IDENTITY_PATH", - temp.path().join("missing-user.json"), - ) - .env("MYC_DISCOVERY_PUBLIC_RELAYS", "not-a-relay") - .env("MYC_TRANSPORT_RELAYS", "not-a-relay") - .env("MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MILLIS", "0") - .arg("--env-file") - .arg(&env_path) - .arg("status") - .arg("--view") - .arg("signer") - .output() - .expect("run myc signer status"); - - assert!(output.status.success()); - let value: Value = serde_json::from_slice(&output.stdout).expect("signer status json"); - assert_eq!(value["ready"], true); - assert_eq!(value["custody"]["signer"]["resolved"], true); - assert_eq!(value["custody"]["user"]["resolved"], true); -} - -#[test] -fn status_summary_command_emits_machine_readable_json() { - let temp = tempfile::tempdir().expect("tempdir"); - let env_path = write_env_file(&temp); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("status") - .arg("--view") - .arg("summary") - .output() - .expect("run myc status"); - - assert!(output.status.success()); - 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"]["path_overrides"]["canonical_root_selection"], - "profile_root_env_or_repo_wrapper" - ); - assert_eq!( - value["runtime_contract"]["path_overrides"]["canonical_subordinate_path_override"], - "config_artifact" - ); - assert_eq!( - value["runtime_contract"]["path_overrides"]["leaf_path_env_posture"], - "runtime_owned_leaf_overrides" - ); - assert_eq!( - value["runtime_contract"]["allowed_profiles"], - json!(["interactive_user", "service_host", "repo_local"]) - ); - assert_eq!( - value["runtime_contract"]["default_shared_secret_backend"], - "encrypted_file" - ); - assert_eq!( - value["runtime_contract"]["allowed_shared_secret_backends"], - json!([ - "encrypted_file", - "host_vault", - "external_command", - "plaintext_file" - ]) - ); - assert_eq!( - value["runtime_contract"]["runtime_specific_custody_modes"], - json!(["managed_account"]) - ); - assert_eq!(value["runtime_contract"]["host_vault_policy"], "desktop"); - assert_eq!(value["custody"]["signer"]["backend"], "encrypted_file"); - assert_eq!(value["custody"]["signer"]["resolved"], true); - assert_eq!(value["persistence"]["signer_state"]["backend"], "json_file"); - assert_eq!( - value["persistence"]["runtime_audit"]["backend"], - "jsonl_file" - ); - assert_eq!(value["delivery_outbox"]["status"], "healthy"); - assert_eq!(value["delivery_outbox"]["ready"], true); - assert_eq!(value["delivery_outbox"]["total_job_count"], 0); - assert_eq!(value["transport"]["enabled"], false); -} - -#[test] -fn metrics_command_emits_json_and_prometheus_formats() { - let temp = tempfile::tempdir().expect("tempdir"); - let env_path = write_env_file(&temp); - let config = myc::MycConfig::load_from_env_path(&env_path).expect("load config"); - let runtime = MycRuntime::bootstrap(config).expect("runtime"); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::AuthReplayRestore, - MycOperationAuditOutcome::Restored, - None, - None, - 1, - 0, - "restored pending request after failed replay publish", - )); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::DeliveryRecovery, - MycOperationAuditOutcome::Succeeded, - None, - None, - 1, - 1, - "recovered 1/1 delivery outbox job(s); republished 1", - )); - let outbox_record = MycDeliveryOutboxRecord::new( - MycDeliveryOutboxKind::DiscoveryHandlerPublish, - signed_event(runtime.signer_identity()), - vec!["wss://relay.example.com".parse().expect("relay url")], - ) - .expect("outbox record"); - runtime - .delivery_outbox_store() - .enqueue(&outbox_record) - .expect("enqueue outbox record"); - - let json_output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("metrics") - .arg("--format") - .arg("json") - .output() - .expect("run myc metrics json"); - assert!(json_output.status.success()); - let json_value: Value = serde_json::from_slice(&json_output.stdout).expect("metrics json"); - assert_eq!(json_value["runtime_replay_restore_count"], 1); - assert_eq!(json_value["delivery_recovery_success_count"], 1); - assert_eq!(json_value["delivery_outbox_total"], 1); - assert_eq!(json_value["delivery_outbox_queued_count"], 1); - - let prometheus_output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("metrics") - .arg("--format") - .arg("prometheus") - .output() - .expect("run myc metrics prometheus"); - assert!(prometheus_output.status.success()); - let rendered = String::from_utf8(prometheus_output.stdout).expect("utf8 metrics"); - assert!(rendered.contains("myc_runtime_replay_restore_total 1")); - assert!(rendered.contains("myc_delivery_recovery_success_total 1")); - assert!(rendered.contains("myc_delivery_outbox_total 1")); - assert!(rendered.contains("myc_signer_request_total 0")); -} - -#[test] -fn custody_status_command_reports_role_backend_details() { - let temp = tempfile::tempdir().expect("tempdir"); - let env_path = write_env_file(&temp); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("custody") - .arg("status") - .arg("--role") - .arg("signer") - .output() - .expect("run myc custody status"); - - assert!(output.status.success()); - let value: Value = serde_json::from_slice(&output.stdout).expect("custody status json"); - assert_eq!(value["backend"], "encrypted_file"); - assert_eq!(value["resolved"], true); - 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" - ]) - ); - assert_eq!( - value["runtime_specific_custody_modes"], - json!(["managed_account"]) - ); - assert_eq!(value["host_vault_policy"], "desktop"); - assert_eq!( - value["identity_id"], - "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" - ); -} - -#[test] -fn custody_export_import_and_rotate_nip49_for_encrypted_file_backend() { - let temp = tempfile::tempdir().expect("tempdir"); - let env_path = write_env_file(&temp); - let signer_path = temp.path().join("signer.json"); - let export_path = temp.path().join("signer.ncryptsec"); - let key_path = myc::identity_files::encrypted_identity_wrapping_key_path(&signer_path); - - let export_output = Command::new(env!("CARGO_BIN_EXE_myc")) - .env("MYC_TEST_PASSWORD", "correct horse battery staple") - .arg("--env-file") - .arg(&env_path) - .arg("custody") - .arg("export-nip49") - .arg("--role") - .arg("signer") - .arg("--out") - .arg(&export_path) - .arg("--password-env") - .arg("MYC_TEST_PASSWORD") - .output() - .expect("run myc custody export-nip49"); - - assert!(export_output.status.success()); - let export_value: Value = - serde_json::from_slice(&export_output.stdout).expect("export-nip49 json"); - assert_eq!(export_value["format"], "nip49"); - assert_eq!(export_value["out"], export_path.display().to_string()); - let exported = fs::read_to_string(&export_path).expect("read exported ncryptsec"); - assert!(exported.starts_with("ncryptsec1")); - - fs::remove_file(&signer_path).expect("remove signer identity"); - fs::remove_file(&key_path).expect("remove signer wrapping key"); - - let import_output = Command::new(env!("CARGO_BIN_EXE_myc")) - .env("MYC_TEST_PASSWORD", "correct horse battery staple") - .arg("--env-file") - .arg(&env_path) - .arg("custody") - .arg("import-nip49") - .arg("--role") - .arg("signer") - .arg("--path") - .arg(&export_path) - .arg("--password-env") - .arg("MYC_TEST_PASSWORD") - .output() - .expect("run myc custody import-nip49"); - - assert!(import_output.status.success()); - let import_value: Value = - serde_json::from_slice(&import_output.stdout).expect("import-nip49 json"); - assert_eq!(import_value["format"], "nip49"); - assert_eq!(import_value["status"]["resolved"], true); - let restored = myc::identity_files::load_encrypted_identity(&signer_path) - .expect("load restored encrypted identity"); - assert_eq!( - restored.id().to_string(), - "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" - ); - - let key_before_rotation = fs::read(&key_path).expect("read key before rotation"); - let rotate_output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("custody") - .arg("rotate") - .arg("--role") - .arg("signer") - .output() - .expect("run myc custody rotate"); - - assert!(rotate_output.status.success()); - let rotate_value: Value = serde_json::from_slice(&rotate_output.stdout).expect("rotate json"); - assert_eq!(rotate_value["action"], "rotate"); - assert_eq!(rotate_value["status"]["resolved"], true); - let key_after_rotation = fs::read(&key_path).expect("read key after rotation"); - assert_ne!(key_before_rotation, key_after_rotation); -} diff --git a/tests/persistence_cli.rs b/tests/persistence_cli.rs @@ -1,513 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use myc::host_identity::RadrootsIdentity; -use myc::signer::prelude::RadrootsNostrSignerConnectionDraft; -use myc::{ - MycConfig, MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, - MycRuntime, MycRuntimeAuditBackend, MycSignerStateBackend, -}; -use nostr::PublicKey; -use serde_json::Value; - -fn write_identity(path: &Path, secret_key: &str) { - let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); - myc::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); -} - -fn copy_dir_recursive(source: &Path, destination: &Path) { - std::fs::create_dir_all(destination).expect("create copied dir"); - for entry in std::fs::read_dir(source).expect("read copied dir source") { - let entry = entry.expect("dir entry"); - let source_path = entry.path(); - let destination_path = destination.join(entry.file_name()); - if source_path.is_dir() { - copy_dir_recursive(&source_path, &destination_path); - } else { - std::fs::copy(&source_path, &destination_path).expect("copy file"); - } - } -} - -fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, MycConfig) { - let mut json_config = MycConfig::default(); - json_config.paths.state_dir = temp.path().join("state"); - json_config.paths.signer_identity_path = temp.path().join("signer.json"); - json_config.paths.user_identity_path = temp.path().join("user.json"); - - write_identity( - &json_config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &json_config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - - let runtime = MycRuntime::bootstrap(json_config.clone()).expect("json runtime"); - let manager = runtime.signer_manager().expect("manager"); - let connection = manager - .register_connection(RadrootsNostrSignerConnectionDraft::new( - PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") - .expect("pubkey"), - runtime.user_public_identity(), - )) - .expect("register connection"); - runtime.record_operation_audit(&MycOperationAuditRecord::new( - MycOperationAuditKind::ListenerResponsePublish, - MycOperationAuditOutcome::Succeeded, - Some(&connection.connection_id), - Some("request-1"), - 1, - 1, - "publish succeeded", - )); - - let mut sqlite_config = json_config.clone(); - sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; - sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; - - (json_config, sqlite_config) -} - -fn migrate_to_sqlite(temp: &tempfile::TempDir) -> MycConfig { - let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(temp); - let env_path = temp.path().join("myc-sqlite.env"); - std::fs::write( - &env_path, - sqlite_config.to_env_string().expect("render sqlite env"), - ) - .expect("write sqlite env"); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("persistence") - .arg("import-json-to-sqlite") - .output() - .expect("run import"); - assert!(output.status.success(), "{:?}", output); - - sqlite_config -} - -fn write_env(path: &Path, config: &MycConfig) { - std::fs::write(path, config.to_env_string().expect("render env")).expect("write env"); -} - -fn run_myc(env_path: &Path, args: &[&str]) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_myc")); - command.arg("--env-file").arg(env_path); - for arg in args { - command.arg(arg); - } - command.output().expect("run myc") -} - -#[test] -fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() { - let temp = tempfile::tempdir().expect("tempdir"); - let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(&temp); - let env_path = temp.path().join("myc-sqlite.env"); - std::fs::write( - &env_path, - sqlite_config.to_env_string().expect("render sqlite env"), - ) - .expect("write env"); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("persistence") - .arg("import-json-to-sqlite") - .output() - .expect("run import"); - - assert!(output.status.success(), "{:?}", output); - - let parsed: Value = serde_json::from_slice(&output.stdout).expect("import json"); - assert_eq!(parsed["signer_state"]["connection_count"], 1); - assert_eq!(parsed["signer_state"]["request_audit_count"], 0); - assert_eq!(parsed["runtime_audit"]["record_count"], 1); - assert!( - parsed["signer_state"]["destination_path"] - .as_str() - .expect("sqlite signer destination") - .ends_with("signer-state.sqlite") - ); - assert!( - parsed["runtime_audit"]["destination_path"] - .as_str() - .expect("sqlite audit destination") - .ends_with("operations.sqlite") - ); - - let sqlite_runtime = MycRuntime::bootstrap(sqlite_config.clone()).expect("sqlite runtime"); - assert_eq!( - sqlite_runtime - .signer_manager() - .expect("sqlite manager") - .list_connections() - .expect("sqlite connections") - .len(), - 1 - ); - assert_eq!( - sqlite_runtime - .operation_audit_store() - .list_all() - .expect("sqlite audit records") - .len(), - 1 - ); - - let rerun = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&env_path) - .arg("persistence") - .arg("import-json-to-sqlite") - .output() - .expect("rerun import"); - - assert!(!rerun.status.success(), "{:?}", rerun); - let stderr = String::from_utf8(rerun.stderr).expect("rerun stderr"); - assert!(stderr.contains("sqlite signer-state destination")); -} - -#[test] -fn persistence_backup_cli_copies_sqlite_state_and_identity_files() { - let source = tempfile::tempdir().expect("source tempdir"); - let sqlite_config = migrate_to_sqlite(&source); - let env_path = source.path().join("sqlite.env"); - write_env(&env_path, &sqlite_config); - let backup_dir = source.path().join("backup"); - - let output = run_myc(&env_path, &["persistence", "backup", "--out"]); - assert!( - !output.status.success(), - "missing backup path should fail clap parsing" - ); - - let output = run_myc( - &env_path, - &[ - "persistence", - "backup", - "--out", - backup_dir.to_str().expect("backup dir str"), - ], - ); - assert!(output.status.success(), "{:?}", output); - - let parsed: Value = serde_json::from_slice(&output.stdout).expect("backup json"); - assert_eq!(parsed["signer_identity_reference"]["copied_file_count"], 2); - assert_eq!(parsed["user_identity_reference"]["copied_file_count"], 2); - assert_eq!( - parsed["discovery_app_identity_reference"], - Value::Null, - "default config reuses signer identity and should not emit a dedicated discovery backup" - ); - assert!(backup_dir.join("manifest.json").is_file()); - assert!( - backup_dir - .join("state") - .join("signer-state.sqlite") - .is_file() - ); - assert!( - backup_dir - .join("state") - .join("delivery-outbox.sqlite") - .is_file() - ); - assert!( - backup_dir - .join("state") - .join("audit") - .join("operations.sqlite") - .is_file() - ); - assert!( - backup_dir - .join("identity-references") - .join("signer") - .join("path") - .is_file() - ); - assert!( - backup_dir - .join("identity-references") - .join("signer") - .join("encrypted-key-path") - .is_file() - ); - assert!( - backup_dir - .join("identity-references") - .join("user") - .join("path") - .is_file() - ); - assert!( - backup_dir - .join("identity-references") - .join("user") - .join("encrypted-key-path") - .is_file() - ); -} - -#[test] -fn persistence_backup_cli_rejects_destination_inside_state_dir() { - let source = tempfile::tempdir().expect("source tempdir"); - let sqlite_config = migrate_to_sqlite(&source); - let env_path = source.path().join("sqlite.env"); - write_env(&env_path, &sqlite_config); - let nested_backup_dir = sqlite_config.paths.state_dir.join("backup"); - - let output = run_myc( - &env_path, - &[ - "persistence", - "backup", - "--out", - nested_backup_dir.to_str().expect("nested backup dir str"), - ], - ); - - assert!(!output.status.success(), "{:?}", output); - let stderr = String::from_utf8(output.stderr).expect("backup stderr"); - assert!(stderr.contains("cannot copy")); -} - -#[test] -fn persistence_restore_cli_restores_backup_and_verify_restore_passes() { - let source = tempfile::tempdir().expect("source tempdir"); - let sqlite_config = migrate_to_sqlite(&source); - let sqlite_env = source.path().join("sqlite.env"); - write_env(&sqlite_env, &sqlite_config); - let backup_dir = source.path().join("backup"); - let backup = run_myc( - &sqlite_env, - &[ - "persistence", - "backup", - "--out", - backup_dir.to_str().expect("backup dir str"), - ], - ); - assert!(backup.status.success(), "{:?}", backup); - - let restored = tempfile::tempdir().expect("restored tempdir"); - let restored_signer = restored.path().join("signer.json"); - let restored_user = restored.path().join("user.json"); - - let mut restored_config = sqlite_config.clone(); - restored_config.paths.state_dir = restored.path().join("state"); - restored_config.paths.signer_identity_path = restored_signer; - restored_config.paths.user_identity_path = restored_user; - let restored_env = restored.path().join("restored.env"); - write_env(&restored_env, &restored_config); - - let restore = run_myc( - &restored_env, - &[ - "persistence", - "restore", - "--from", - backup_dir.to_str().expect("backup dir str"), - ], - ); - assert!(restore.status.success(), "{:?}", restore); - - let restore_json: Value = serde_json::from_slice(&restore.stdout).expect("restore json"); - assert_eq!( - restore_json["signer_identity_reference"]["restored_file_count"], - 2 - ); - assert_eq!( - restore_json["user_identity_reference"]["restored_file_count"], - 2 - ); - assert!( - restored_config - .paths - .state_dir - .join("signer-state.sqlite") - .is_file() - ); - assert!(restored_config.paths.signer_identity_path.is_file()); - assert!(restored_config.paths.user_identity_path.is_file()); - assert!( - myc::identity_files::encrypted_identity_wrapping_key_path( - &restored_config.paths.signer_identity_path - ) - .is_file() - ); - assert!( - myc::identity_files::encrypted_identity_wrapping_key_path( - &restored_config.paths.user_identity_path - ) - .is_file() - ); - - let output = run_myc(&restored_env, &["persistence", "verify-restore"]); - - assert!(output.status.success(), "{:?}", output); - - let parsed: Value = serde_json::from_slice(&output.stdout).expect("verify restore json"); - assert_eq!(parsed["signer_state"]["backend"], "sqlite"); - assert_eq!(parsed["signer_state"]["connection_count"], 1); - assert_eq!(parsed["runtime_audit"]["backend"], "sqlite"); - assert_eq!(parsed["runtime_audit"]["record_count"], 1); - assert_eq!(parsed["delivery_outbox"]["queued_job_count"], 0); - assert_eq!(parsed["delivery_outbox"]["unfinished_job_count"], 0); - assert!( - parsed["delivery_outbox"]["path"] - .as_str() - .expect("delivery outbox path") - .ends_with("delivery-outbox.sqlite") - ); -} - -#[test] -fn persistence_verify_restore_cli_rejects_missing_outbox_file() { - let source = tempfile::tempdir().expect("source tempdir"); - let sqlite_config = migrate_to_sqlite(&source); - - let restored = tempfile::tempdir().expect("restored tempdir"); - let restored_state_dir = restored.path().join("state"); - copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir); - let restored_signer = restored.path().join("signer.json"); - let restored_user = restored.path().join("user.json"); - std::fs::copy(&sqlite_config.paths.signer_identity_path, &restored_signer) - .expect("copy signer identity"); - std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) - .expect("copy user identity"); - std::fs::remove_file(restored_state_dir.join("delivery-outbox.sqlite")) - .expect("remove restored outbox"); - - let mut restored_config = sqlite_config.clone(); - restored_config.paths.state_dir = restored_state_dir; - restored_config.paths.signer_identity_path = restored_signer; - restored_config.paths.user_identity_path = restored_user; - let restored_env = restored.path().join("restored.env"); - std::fs::write( - &restored_env, - restored_config - .to_env_string() - .expect("render restored env"), - ) - .expect("write restored env"); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&restored_env) - .arg("persistence") - .arg("verify-restore") - .output() - .expect("run verify restore"); - - assert!(!output.status.success(), "{:?}", output); - let stderr = String::from_utf8(output.stderr).expect("verify restore stderr"); - assert!( - stderr.contains("persistence verify-restore requires an existing delivery outbox file") - ); -} - -#[test] -fn persistence_restore_cli_rejects_non_empty_destination() { - let source = tempfile::tempdir().expect("source tempdir"); - let sqlite_config = migrate_to_sqlite(&source); - let sqlite_env = source.path().join("sqlite.env"); - write_env(&sqlite_env, &sqlite_config); - let backup_dir = source.path().join("backup"); - let backup = run_myc( - &sqlite_env, - &[ - "persistence", - "backup", - "--out", - backup_dir.to_str().expect("backup dir str"), - ], - ); - assert!(backup.status.success(), "{:?}", backup); - - let restored = tempfile::tempdir().expect("restored tempdir"); - let mut restored_config = sqlite_config.clone(); - restored_config.paths.state_dir = restored.path().join("state"); - restored_config.paths.signer_identity_path = restored.path().join("signer.json"); - restored_config.paths.user_identity_path = restored.path().join("user.json"); - std::fs::create_dir_all(&restored_config.paths.state_dir).expect("create restored state dir"); - std::fs::write( - restored_config.paths.state_dir.join("existing.txt"), - "occupied", - ) - .expect("write occupied marker"); - let restored_env = restored.path().join("restored.env"); - write_env(&restored_env, &restored_config); - - let restore = run_myc( - &restored_env, - &[ - "persistence", - "restore", - "--from", - backup_dir.to_str().expect("backup dir str"), - ], - ); - - assert!(!restore.status.success(), "{:?}", restore); - let stderr = String::from_utf8(restore.stderr).expect("restore stderr"); - assert!(stderr.contains("restore state directory")); -} - -#[test] -fn persistence_verify_restore_cli_rejects_signer_identity_mismatch() { - let source = tempfile::tempdir().expect("source tempdir"); - let sqlite_config = migrate_to_sqlite(&source); - - let restored = tempfile::tempdir().expect("restored tempdir"); - let restored_state_dir = restored.path().join("state"); - copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir); - let restored_signer = restored.path().join("other-signer.json"); - let restored_user = restored.path().join("user.json"); - write_identity( - &restored_signer, - "3333333333333333333333333333333333333333333333333333333333333333", - ); - std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) - .expect("copy user identity"); - std::fs::copy( - myc::identity_files::encrypted_identity_wrapping_key_path( - &sqlite_config.paths.user_identity_path, - ), - myc::identity_files::encrypted_identity_wrapping_key_path(&restored_user), - ) - .expect("copy user identity wrapping key"); - - let mut restored_config = sqlite_config.clone(); - restored_config.paths.state_dir = restored_state_dir; - restored_config.paths.signer_identity_path = restored_signer; - restored_config.paths.user_identity_path = restored_user; - let restored_env = restored.path().join("restored.env"); - std::fs::write( - &restored_env, - restored_config - .to_env_string() - .expect("render restored env"), - ) - .expect("write restored env"); - - let output = Command::new(env!("CARGO_BIN_EXE_myc")) - .arg("--env-file") - .arg(&restored_env) - .arg("persistence") - .arg("verify-restore") - .output() - .expect("run verify restore"); - - assert!(!output.status.success(), "{:?}", output); - let stderr = String::from_utf8(output.stderr).expect("verify restore stderr"); - assert!(stderr.contains("does not match persisted signer identity")); -} diff --git a/tests/services_hardening_legacy_removal.rs b/tests/services_hardening_legacy_removal.rs @@ -0,0 +1,105 @@ +#![forbid(unsafe_code)] + +use std::path::Path; +use std::process::Command; + +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const MAIN_SOURCE: &str = include_str!("../src/main.rs"); +const CONFIG_SOURCE: &str = include_str!("../src/config.rs"); +const PATHS_SOURCE: &str = include_str!("../src/paths.rs"); +const LOGGING_SOURCE: &str = include_str!("../src/logging.rs"); +const PERSISTENCE_SOURCE: &str = include_str!("../src/persistence.rs"); + +#[test] +fn prototype_environment_and_cli_sources_are_absent() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + for relative in [ + ".env.example", + "src/cli.rs", + "src/bin/myc_repo_local_identity_bootstrap.rs", + "tests/discovery_cli.rs", + "tests/logging_run.rs", + "tests/operability_cli.rs", + "tests/persistence_cli.rs", + ] { + assert!( + !root.join(relative).exists(), + "legacy source remains: {relative}" + ); + } + + let governed_sources = [ + LIB_SOURCE, + CONFIG_SOURCE, + PATHS_SOURCE, + LOGGING_SOURCE, + PERSISTENCE_SOURCE, + ] + .join("\n"); + for forbidden in [ + "pub mod cli;", + "run_from_env", + "load_from_default_env_path", + "load_from_env_path", + "from_env_str", + "to_env_string", + "DEFAULT_ENV_PATH", + "config_env_path", + "process_path_selection", + "path_selection_from_entries", + "parse_path_profile_env", + "\"MYC_", + ] { + assert!( + !governed_sources.contains(forbidden), + "legacy selector remains: {forbidden}" + ); + } +} + +#[test] +fn binary_uses_only_the_hardened_parser_and_fails_closed_before_dispatch() { + assert!(MAIN_SOURCE.contains("parse_myc_cli_v1_from(std::env::args_os())")); + assert!(!MAIN_SOURCE.contains("MycConfig")); + assert!(!MAIN_SOURCE.contains("MycRuntime")); + + let missing = Command::new(env!("CARGO_BIN_EXE_myc")) + .env("MYC_PATHS_PROFILE", "service_host") + .env("MYC_SERVICE_INSTANCE_NAME", "implicit") + .output() + .expect("run missing-selector case"); + assert_eq!(missing.status.code(), Some(2)); + assert_eq!( + String::from_utf8(missing.stderr).expect("utf8 stderr"), + "myc: command-line arguments are invalid\n" + ); + + let admitted = Command::new(env!("CARGO_BIN_EXE_myc")) + .args(["--profile", "service-host", "--instance", "primary", "run"]) + .output() + .expect("run admitted command"); + assert_eq!(admitted.status.code(), Some(1)); + assert_eq!( + String::from_utf8(admitted.stderr).expect("utf8 stderr"), + "myc: command execution is unavailable\n" + ); +} + +#[test] +fn removed_alias_and_leaf_arguments_fail_without_echoing_values() { + for arguments in [ + vec!["--env-file", "/sensitive/config.env", "run"], + vec!["metrics"], + vec!["run", "--relay-url", "wss://sensitive.example"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_myc")) + .args(["--profile", "service-host", "--instance", "primary"]) + .args(arguments) + .output() + .expect("run forbidden command"); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert_eq!(stderr, "myc: command-line arguments are invalid\n"); + assert!(!stderr.contains("sensitive")); + } +}