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:
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(¬ify);
-
- 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, ¬ify).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"));
+ }
+}