From 839618643159afaa166927efce13c0da4fda940e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:04:55 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20arbitrary=20command=20execution=20via=20profile=20conf?= =?UTF-8?q?iguration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: bitcoiner-dev <75873427+bitcoiner-dev@users.noreply.github.com> --- .jules/sentinel.md | 5 + src/cli.rs | 17 +- src/commands/account.rs | 2 +- src/commands/address.rs | 2 +- src/commands/config.rs | 7 +- src/commands/doctor.rs | 2 +- src/commands/inscription.rs | 6 +- src/commands/lock.rs | 2 +- src/commands/mod.rs | 2 +- src/commands/offer.rs | 207 ++++++++----- src/commands/psbt.rs | 4 +- src/commands/scenario.rs | 6 +- src/commands/setup.rs | 34 ++- src/commands/snapshot.rs | 10 +- src/commands/sync.rs | 6 +- src/commands/wait.rs | 2 +- src/commands/wallet.rs | 14 +- src/main.rs | 17 +- src/output.rs | 584 ++++++++++++++++++++++++++++++------ src/paths.rs | 2 +- src/presenter/grid.rs | 30 +- src/utils.rs | 13 + src/wallet_service.rs | 22 -- src/wizard/mod.rs | 1 - tests/contract_v1.rs | 4 +- 25 files changed, 747 insertions(+), 254 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index b58d736..e85339f 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,8 @@ **Vulnerability:** The CLI application creates sensitive configuration files and directories (like wallets and snapshot data) using standard `fs::create_dir_all` and `fs::write` in Rust. These standard functions create files/directories using the system's default umask, which typically allows other users on the same Unix-like system to read the sensitive files. **Learning:** This could lead to a local privilege escalation or exposure of sensitive user data if the user runs the CLI on a shared machine. Relying on default system configurations for sensitive files is unsafe. **Prevention:** Always use `std::os::unix::fs::DirBuilderExt` and `std::os::unix::fs::OpenOptionsExt` to explicitly set file permissions (e.g., `0o700` for directories and `0o600` for files) when creating sensitive data on disk. + +## 2024-05-23 - Arbitrary Command Execution via Configuration Profiles +**Vulnerability:** The application executes `bitcoin-cli` using a user-provided configuration path (`profile.bitcoin_cli`) directly in `std::process::Command::new`, without verifying the actual executable name. +**Learning:** This allows an attacker who gains write access to the `.zinc-cli/profiles/*.json` configuration files to set `bitcoin_cli` to any arbitrary malicious script or binary on the system (e.g., `rm -rf /` or `/bin/sh`). When the app subsequently attempts to use `bitcoin-cli`, it executes the malicious payload instead. +**Prevention:** Always validate and restrict binary names executed via external commands, even when the path is loaded from a "trusted" local configuration file. Centralize command execution logic and enforce a strict whitelist of allowed binary filenames (e.g., exactly `bitcoin-cli` or `bitcoin-cli.exe`). diff --git a/src/cli.rs b/src/cli.rs index f2a5930..e7711e8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,8 +9,6 @@ pub enum PolicyMode { Strict, } - - #[derive(Parser, Debug, Clone)] #[command( name = "zinc-cli", @@ -21,8 +19,11 @@ pub struct Cli { #[command(subcommand)] pub command: Command, - - #[arg(long, global = true, help = "Agent mode (machine-readable JSON output)")] + #[arg( + long, + global = true, + help = "Agent mode (machine-readable JSON output)" + )] pub agent: bool, #[arg(long, global = true, help = "Suppress all non-error output")] @@ -118,11 +119,7 @@ pub struct Cli { )] pub network_retries: u32, - #[arg( - long, - global = true, - help = "Disable inscription thumbnails" - )] + #[arg(long, global = true, help = "Disable inscription thumbnails")] pub no_thumb: bool, #[arg( @@ -195,7 +192,7 @@ pub struct SetupArgs { pub default_ord_url: Option, #[arg(long)] pub quiet_default: Option, - + #[arg(long)] pub restore_mnemonic: Option, #[arg(long)] diff --git a/src/commands/account.rs b/src/commands/account.rs index 969e576..23d2873 100644 --- a/src/commands/account.rs +++ b/src/commands/account.rs @@ -1,8 +1,8 @@ use crate::cli::{AccountAction, AccountArgs, Cli}; use crate::error::AppError; +use crate::output::CommandOutput; use crate::wallet_service::{now_unix, AccountState}; use crate::{load_wallet_session, profile_path, read_profile, write_profile}; -use crate::output::CommandOutput; use zinc_core::Account; pub async fn run(cli: &Cli, args: &AccountArgs) -> Result { diff --git a/src/commands/address.rs b/src/commands/address.rs index 03ec922..29376dd 100644 --- a/src/commands/address.rs +++ b/src/commands/address.rs @@ -1,8 +1,8 @@ use crate::cli::{AddressArgs, AddressKind, Cli}; use crate::error::AppError; +use crate::output::CommandOutput; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; -use crate::output::CommandOutput; pub async fn run(cli: &Cli, args: &AddressArgs) -> Result { let mut session = load_wallet_session(cli)?; diff --git a/src/commands/config.rs b/src/commands/config.rs index 1020e92..2a9f25b 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -35,7 +35,12 @@ pub async fn run(cli: &Cli, args: &ConfigArgs) -> Result Result { diff --git a/src/commands/inscription.rs b/src/commands/inscription.rs index 01bd0d7..c7e77a4 100644 --- a/src/commands/inscription.rs +++ b/src/commands/inscription.rs @@ -12,11 +12,7 @@ pub async fn run(cli: &Cli, _args: &InscriptionArgs) -> Result Result { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e97ad3b..10dd78f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,6 +12,6 @@ pub mod setup; pub mod snapshot; pub mod sync; pub mod tx; +pub mod version; pub mod wait; pub mod wallet; -pub mod version; diff --git a/src/commands/offer.rs b/src/commands/offer.rs index 415b53f..f3238e9 100644 --- a/src/commands/offer.rs +++ b/src/commands/offer.rs @@ -4,11 +4,11 @@ use crate::commands::psbt::{analyze_psbt_with_policy, enforce_policy_mode}; use crate::config::NetworkArg; use crate::error::AppError; use crate::network_retry::with_network_retry; -use crate::presenter::thumbnail::{render_non_image_badge, print_thumbnail}; +use crate::output::CommandOutput; +use crate::presenter::thumbnail::{print_thumbnail, render_non_image_badge}; use crate::utils::{maybe_write_text, resolve_psbt_source}; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; -use crate::output::CommandOutput; use serde_json::{json, Value}; use std::io::Read; use std::time::{SystemTime, UNIX_EPOCH}; @@ -345,66 +345,132 @@ async fn finalize_offer_output( let hide_inscription_ids = cli.thumb_enabled(); match action { - OfferAction::Create { inscription, .. } => { - Ok(CommandOutput::OfferCreate { - inscription: inscription.clone(), - ask_sats: response.get("ask_sats").and_then(Value::as_u64).unwrap_or(0), - fee_rate_sat_vb: response.get("fee_rate_sat_vb").and_then(Value::as_u64).unwrap_or(0), - seller_address: response.get("seller_address").and_then(Value::as_str).unwrap_or("").to_string(), - seller_outpoint: response.get("seller_outpoint").and_then(Value::as_str).unwrap_or("").to_string(), - seller_pubkey_hex: response.get("offer").and_then(|o| o.get("seller_pubkey_hex")).and_then(Value::as_str).unwrap_or("").to_string(), - expires_at_unix: response.get("offer").and_then(|o| o.get("expires_at_unix")).and_then(Value::as_i64).unwrap_or(0), - thumbnail_lines, - hide_inscription_ids, - raw_response: response, - }) - } - OfferAction::Publish { .. } => { - Ok(CommandOutput::OfferPublish { - event_id: response.get("event").and_then(|v| v.get("id")).and_then(Value::as_str).unwrap_or("").to_string(), - accepted_relays: response.get("accepted_relays").and_then(Value::as_u64).unwrap_or(0), - total_relays: response.get("total_relays").and_then(Value::as_u64).unwrap_or(0), - publish_results: response.get("publish_results").and_then(Value::as_array).unwrap_or(&vec![]).clone(), - raw_response: response, - }) - } - OfferAction::Discover { .. } => { - Ok(CommandOutput::OfferDiscover { - event_count: response.get("event_count").and_then(Value::as_u64).unwrap_or(0), - offer_count: response.get("offer_count").and_then(Value::as_u64).unwrap_or(0), - offers: response.get("offers").and_then(Value::as_array).unwrap_or(&vec![]).clone(), - thumbnail_lines, - hide_inscription_ids, - raw_response: response, - }) - } - OfferAction::SubmitOrd { .. } => { - Ok(CommandOutput::OfferSubmitOrd { - ord_url: response.get("ord_url").and_then(Value::as_str).unwrap_or("").to_string(), - submitted: true, - raw_response: response, - }) - } - OfferAction::ListOrd => { - Ok(CommandOutput::OfferListOrd { - ord_url: response.get("ord_url").and_then(Value::as_str).unwrap_or("").to_string(), - count: response.get("count").and_then(Value::as_u64).unwrap_or(0), - offers: response.get("offers").and_then(Value::as_array).unwrap_or(&vec![]).clone(), - raw_response: response, - }) - } - OfferAction::Accept { .. } => { - Ok(CommandOutput::OfferAccept { - inscription: response.get("inscription_id").and_then(Value::as_str).unwrap_or("").to_string(), - ask_sats: response.get("ask_sats").and_then(Value::as_u64).unwrap_or(0), - txid: response.get("txid").and_then(Value::as_str).unwrap_or("-").to_string(), - dry_run: response.get("dry_run").and_then(Value::as_bool).unwrap_or(false), - inscription_risk: response.get("inscription_risk").and_then(Value::as_str).unwrap_or("").to_string(), - thumbnail_lines, - hide_inscription_ids, - raw_response: response, - }) - } + OfferAction::Create { inscription, .. } => Ok(CommandOutput::OfferCreate { + inscription: inscription.clone(), + ask_sats: response + .get("ask_sats") + .and_then(Value::as_u64) + .unwrap_or(0), + fee_rate_sat_vb: response + .get("fee_rate_sat_vb") + .and_then(Value::as_u64) + .unwrap_or(0), + seller_address: response + .get("seller_address") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + seller_outpoint: response + .get("seller_outpoint") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + seller_pubkey_hex: response + .get("offer") + .and_then(|o| o.get("seller_pubkey_hex")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + expires_at_unix: response + .get("offer") + .and_then(|o| o.get("expires_at_unix")) + .and_then(Value::as_i64) + .unwrap_or(0), + thumbnail_lines, + hide_inscription_ids, + raw_response: response, + }), + OfferAction::Publish { .. } => Ok(CommandOutput::OfferPublish { + event_id: response + .get("event") + .and_then(|v| v.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + accepted_relays: response + .get("accepted_relays") + .and_then(Value::as_u64) + .unwrap_or(0), + total_relays: response + .get("total_relays") + .and_then(Value::as_u64) + .unwrap_or(0), + publish_results: response + .get("publish_results") + .and_then(Value::as_array) + .unwrap_or(&vec![]) + .clone(), + raw_response: response, + }), + OfferAction::Discover { .. } => Ok(CommandOutput::OfferDiscover { + event_count: response + .get("event_count") + .and_then(Value::as_u64) + .unwrap_or(0), + offer_count: response + .get("offer_count") + .and_then(Value::as_u64) + .unwrap_or(0), + offers: response + .get("offers") + .and_then(Value::as_array) + .unwrap_or(&vec![]) + .clone(), + thumbnail_lines, + hide_inscription_ids, + raw_response: response, + }), + OfferAction::SubmitOrd { .. } => Ok(CommandOutput::OfferSubmitOrd { + ord_url: response + .get("ord_url") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + submitted: true, + raw_response: response, + }), + OfferAction::ListOrd => Ok(CommandOutput::OfferListOrd { + ord_url: response + .get("ord_url") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + count: response.get("count").and_then(Value::as_u64).unwrap_or(0), + offers: response + .get("offers") + .and_then(Value::as_array) + .unwrap_or(&vec![]) + .clone(), + raw_response: response, + }), + OfferAction::Accept { .. } => Ok(CommandOutput::OfferAccept { + inscription: response + .get("inscription_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ask_sats: response + .get("ask_sats") + .and_then(Value::as_u64) + .unwrap_or(0), + txid: response + .get("txid") + .and_then(Value::as_str) + .unwrap_or("-") + .to_string(), + dry_run: response + .get("dry_run") + .and_then(Value::as_bool) + .unwrap_or(false), + inscription_risk: response + .get("inscription_risk") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + thumbnail_lines, + hide_inscription_ids, + raw_response: response, + }), } } @@ -418,9 +484,12 @@ async fn maybe_offer_thumbnail_lines( } let inscription_id = offer_thumbnail_inscription_id(action, response)?; - let ord_url = resolve_ord_url(cli) - .ok() - .or_else(|| response.get("ord_url").and_then(Value::as_str).map(ToString::to_string))?; + let ord_url = resolve_ord_url(cli).ok().or_else(|| { + response + .get("ord_url") + .and_then(Value::as_str) + .map(ToString::to_string) + })?; let client = OrdClient::new(ord_url); let details = client.get_inscription_details(&inscription_id).await.ok()?; @@ -470,8 +539,6 @@ fn offer_thumbnail_inscription_id(action: &OfferAction, response: &Value) -> Opt } } - - pub fn abbreviate(value: &str, prefix: usize, suffix: usize) -> String { if value.chars().count() <= prefix + suffix + 3 { return value.to_string(); @@ -614,9 +681,7 @@ fn map_offer_error(err: E) -> AppError { #[cfg(test)] mod tests { - use super::{ - abbreviate, assert_offer_expectations, map_offer_error, resolve_offer_source, - }; + use super::{abbreviate, assert_offer_expectations, map_offer_error, resolve_offer_source}; use crate::error::AppError; use zinc_core::OfferEnvelopeV1; @@ -691,6 +756,4 @@ mod tests { let short = abbreviate(value, 6, 4); assert_eq!(short, "123456...cdef"); } - - } diff --git a/src/commands/psbt.rs b/src/commands/psbt.rs index 7c452c1..24fb262 100644 --- a/src/commands/psbt.rs +++ b/src/commands/psbt.rs @@ -1,10 +1,10 @@ use crate::cli::{Cli, PolicyMode, PsbtAction, PsbtArgs}; use crate::error::AppError; use crate::network_retry::with_network_retry; +use crate::output::CommandOutput; use crate::utils::{maybe_write_text, parse_indices, resolve_psbt_source}; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; -use crate::output::CommandOutput; use serde_json::{json, Value}; use zinc_core::*; @@ -61,7 +61,7 @@ pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result "safe_to_send": policy.safe_to_send, "inscription_risk": policy.inscription_risk, "reasons": policy.policy_reasons - }) + }), }) } PsbtAction::Sign { diff --git a/src/commands/scenario.rs b/src/commands/scenario.rs index e38cabb..9dff346 100644 --- a/src/commands/scenario.rs +++ b/src/commands/scenario.rs @@ -1,9 +1,9 @@ use crate::cli::{Cli, ScenarioAction, ScenarioArgs}; use crate::error::AppError; +use crate::output::CommandOutput; use crate::utils::run_bitcoin_cli; use crate::wallet_service::NetworkArg; use crate::{confirm, load_wallet_session, persist_wallet_session, snapshot_dir}; -use crate::output::CommandOutput; use std::fs; pub async fn run(cli: &Cli, args: &ScenarioArgs) -> Result { @@ -121,9 +121,7 @@ pub async fn run(cli: &Cli, args: &ScenarioArgs) -> Result Result config_saved: true, wizard_used: is_tui, profile: Some(values.profile), - data_dir: crate::wallet_service::data_dir(&crate::service_config(cli)).display().to_string(), - default_network: values.default_network.as_deref().or(cli.network.as_deref()).unwrap_or("regtest").to_string(), - default_scheme: values.default_scheme.as_deref().or(cli.scheme.as_deref()).unwrap_or("dual").to_string(), - default_esplora_url: values.default_esplora_url.as_deref().or(cli.esplora_url.as_deref()).unwrap_or_else(|| default_esplora_url(parse_network("regtest").unwrap())).to_string(), - default_ord_url: values.default_ord_url.as_deref().or(cli.ord_url.as_deref()).unwrap_or_else(|| default_ord_url(parse_network("regtest").unwrap())).to_string(), + data_dir: crate::wallet_service::data_dir(&crate::service_config(cli)) + .display() + .to_string(), + default_network: values + .default_network + .as_deref() + .or(cli.network.as_deref()) + .unwrap_or("regtest") + .to_string(), + default_scheme: values + .default_scheme + .as_deref() + .or(cli.scheme.as_deref()) + .unwrap_or("dual") + .to_string(), + default_esplora_url: values + .default_esplora_url + .as_deref() + .or(cli.esplora_url.as_deref()) + .unwrap_or_else(|| default_esplora_url(parse_network("regtest").unwrap())) + .to_string(), + default_ord_url: values + .default_ord_url + .as_deref() + .or(cli.ord_url.as_deref()) + .unwrap_or_else(|| default_ord_url(parse_network("regtest").unwrap())) + .to_string(), quiet_default: values.quiet_default, password_env: values.password_env.clone(), wallet_requested: values.initialize_wallet, diff --git a/src/commands/snapshot.rs b/src/commands/snapshot.rs index 29dd51b..7be1d9e 100644 --- a/src/commands/snapshot.rs +++ b/src/commands/snapshot.rs @@ -1,7 +1,7 @@ use crate::cli::{Cli, SnapshotAction, SnapshotArgs}; use crate::error::AppError; -use crate::{confirm, profile_path, read_profile, snapshot_dir, write_bytes_atomic}; use crate::output::CommandOutput; +use crate::{confirm, profile_path, read_profile, snapshot_dir, write_bytes_atomic}; use std::fs; pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { @@ -22,7 +22,9 @@ pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { if !confirm(&format!("Are you sure you want to restore snapshot '{name}'? This will overwrite your current profile."), cli) { @@ -38,7 +40,9 @@ pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { let mut names = Vec::new(); diff --git a/src/commands/sync.rs b/src/commands/sync.rs index f1845ac..51b7d83 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -1,10 +1,10 @@ use crate::cli::{Cli, SyncArgs, SyncTarget}; use crate::error::AppError; use crate::network_retry::with_network_retry; +use crate::output::CommandOutput; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; use indicatif::{ProgressBar, ProgressStyle}; -use crate::output::CommandOutput; pub async fn run(cli: &Cli, args: &SyncArgs) -> Result { let spinner = if !cli.agent && !cli.quiet { @@ -45,7 +45,9 @@ pub async fn run(cli: &Cli, args: &SyncArgs) -> Result }) .await?; persist_wallet_session(&mut session)?; - CommandOutput::SyncOrdinals { inscriptions: count } + CommandOutput::SyncOrdinals { + inscriptions: count, + } } }; diff --git a/src/commands/wait.rs b/src/commands/wait.rs index 23c7c99..4099ed1 100644 --- a/src/commands/wait.rs +++ b/src/commands/wait.rs @@ -2,9 +2,9 @@ use crate::cli::{Cli, WaitAction, WaitArgs}; use crate::error::AppError; use crate::load_wallet_session; use crate::network_retry::with_network_retry; +use crate::output::CommandOutput; use crate::wallet_service::map_wallet_error; use indicatif::{ProgressBar, ProgressStyle}; -use crate::output::CommandOutput; use std::time::Duration; use tokio::time::sleep; diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index eb183be..b2b277d 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1,5 +1,6 @@ use crate::cli::{Cli, WalletAction, WalletArgs}; use crate::error::AppError; +use crate::output::CommandOutput; use crate::utils::{parse_network, parse_scheme}; use crate::wallet_service::{ decrypt_wallet_internal, default_bitcoin_cli, default_bitcoin_cli_args, default_esplora_url, @@ -8,7 +9,6 @@ use crate::wallet_service::{ }; use crate::{now_unix, profile_path, read_profile, wallet_password, write_profile}; use std::collections::BTreeMap; -use crate::output::CommandOutput; pub async fn run(cli: &Cli, args: &WalletArgs) -> Result { match &args.action { @@ -78,7 +78,11 @@ pub async fn run(cli: &Cli, args: &WalletArgs) -> Result Result { diff --git a/src/main.rs b/src/main.rs index 642376f..bce1898 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,9 @@ mod dashboard; mod error; mod lock; mod network_retry; +mod output; mod paths; mod presenter; -mod output; #[cfg(feature = "ui")] mod ui; mod utils; @@ -118,7 +118,9 @@ async fn main() -> miette::Result<()> { let args: Vec = std::env::args().collect(); let started_at_unix_ms = now_unix_ms(); let is_agent = args.iter().any(|a| a == "--agent") - || std::env::var("ZINC_CLI_OUTPUT").map(|v| v.to_lowercase() == "agent").unwrap_or(false); + || std::env::var("ZINC_CLI_OUTPUT") + .map(|v| v.to_lowercase() == "agent") + .unwrap_or(false); let preparse_log_json = args.iter().any(|a| a == "--log-json") || env_bool("ZINC_CLI_LOG_JSON").unwrap_or(false); let preparse_correlation_id = @@ -320,7 +322,8 @@ async fn main() -> miette::Result<()> { .is_some_and(|k| !k.trim().is_empty()) { if let Some(key) = cli_final.idempotency_key.as_deref() { - let recorded_at = record_idempotent_result(&cli_final, &command_name, &val_json)?; + let recorded_at = + record_idempotent_result(&cli_final, &command_name, &val_json)?; val_json = attach_idempotency_metadata(val_json, key, false, recorded_at); } } @@ -388,7 +391,7 @@ fn resolve_effective_cli(mut cli: Cli) -> Result { cli.ascii = true; } } - + if !cli.quiet { cli.quiet = persisted.quiet.unwrap_or(false); } @@ -920,7 +923,9 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result crate::commands::version::run(cli).await, Command::Doctor => crate::commands::doctor::run(cli).await, #[cfg(feature = "ui")] - Command::Dashboard => crate::dashboard::run(cli).await.map(crate::output::CommandOutput::Generic), + Command::Dashboard => crate::dashboard::run(cli) + .await + .map(crate::output::CommandOutput::Generic), } } @@ -933,8 +938,6 @@ fn is_non_json_rendered_command(command: &Command) -> bool { } } - - #[cfg(test)] mod tests { // legacy tests removed since ViewMode is gone diff --git a/src/output.rs b/src/output.rs index 9d39093..22a5280 100644 --- a/src/output.rs +++ b/src/output.rs @@ -342,23 +342,52 @@ pub struct HumanPresenter { impl HumanPresenter { pub fn new(use_color: bool) -> Self { - Self { - use_color, - } + Self { use_color } } fn print_doctor(&self, output: &CommandOutput) -> String { - if let CommandOutput::Doctor { healthy, esplora_url, esplora_reachable, ord_url, ord_reachable, ord_indexing_height, ord_error } = output { + if let CommandOutput::Doctor { + healthy, + esplora_url, + esplora_reachable, + ord_url, + ord_reachable, + ord_indexing_height, + ord_error, + } = output + { use console::style; let mut out = String::new(); - let status = if *healthy { style("Healthy").green() } else { style("Unhealthy").red() }; + let status = if *healthy { + style("Healthy").green() + } else { + style("Unhealthy").red() + }; out.push_str(&format!("{} {}\n\n", style("Status:").bold(), status)); - - out.push_str(&format!("{} {}\n", style("Esplora RPC:").bold(), esplora_url)); - out.push_str(&format!(" Reachable: {}\n", if *esplora_reachable { style("Yes").green() } else { style("No").red() })); - + + out.push_str(&format!( + "{} {}\n", + style("Esplora RPC:").bold(), + esplora_url + )); + out.push_str(&format!( + " Reachable: {}\n", + if *esplora_reachable { + style("Yes").green() + } else { + style("No").red() + } + )); + out.push_str(&format!("{} {}\n", style("Ord RPC:").bold(), ord_url)); - out.push_str(&format!(" Reachable: {}\n", if *ord_reachable { style("Yes").green() } else { style("No").red() })); + out.push_str(&format!( + " Reachable: {}\n", + if *ord_reachable { + style("Yes").green() + } else { + style("No").red() + } + )); if let Some(h) = ord_indexing_height { out.push_str(&format!(" Height: {}\n", h)); } @@ -371,15 +400,24 @@ impl HumanPresenter { } } - fn print_inscription_list(&self, inscriptions: &[zinc_core::ordinals::Inscription], display_items: &Option>, thumb_mode_enabled: bool) -> String { - use console::style; + fn print_inscription_list( + &self, + inscriptions: &[zinc_core::ordinals::Inscription], + display_items: &Option>, + thumb_mode_enabled: bool, + ) -> String { use crate::presenter::thumbnail::print_thumbnail_at; + use console::style; let mut out = String::new(); if let Some(items) = display_items { let term_width = { let (_, cols) = console::Term::stdout().size(); - if cols > 0 { cols as usize } else { 120 } + if cols > 0 { + cols as usize + } else { + 120 + } }; let card_width: u32 = 24; @@ -391,7 +429,7 @@ impl HumanPresenter { for (col, item) in row_items.iter().enumerate() { let x_offset = col * (card_width as usize + gutter); let header = format!("{}", style(format!("#{}", item.number)).bold().cyan()); - + if col > 0 { print!("\x1b[{}G{header}", x_offset + 1); } else { @@ -400,13 +438,13 @@ impl HumanPresenter { } println!(); - // 2. Pre-allocate vertical space + // 2. Pre-allocate vertical space // Printing images near the bottom of the terminal triggers scrolling. - // The ANSI Save Cursor (\x1b[s) uses absolute screen row. If the screen scrolls + // The ANSI Save Cursor (\x1b[s) uses absolute screen row. If the screen scrolls // between Image 1 and Image 2, Image 2's restored Y-coordinate is physically lower! // We pre-allocate space by printing newlines, then returning up, to guarantee // the viewport won't scroll while viuer prints the row of images. - let space_to_reserve = 14; + let space_to_reserve = 14; for _ in 0..space_to_reserve { println!(); } @@ -432,7 +470,11 @@ impl HumanPresenter { } // 4. Print all badge lines for the row - let max_badge_lines = row_items.iter().map(|i| i.badge_lines.len()).max().unwrap_or(0); + let max_badge_lines = row_items + .iter() + .map(|i| i.badge_lines.len()) + .max() + .unwrap_or(0); for line_idx in 0..max_badge_lines { for (col, item) in row_items.iter().enumerate() { if let Some(line) = item.badge_lines.get(line_idx) { @@ -452,7 +494,10 @@ impl HumanPresenter { } if inscriptions.len() > items.len() { - out.push_str(&format!("... and {} more inscriptions\n", inscriptions.len() - items.len())); + out.push_str(&format!( + "... and {} more inscriptions\n", + inscriptions.len() - items.len() + )); } } else if !thumb_mode_enabled { let table = crate::presenter::inscription::format_inscriptions(inscriptions); @@ -462,7 +507,19 @@ impl HumanPresenter { } fn print_offer_create(&self, output: &CommandOutput) -> String { - if let CommandOutput::OfferCreate { inscription, ask_sats, fee_rate_sat_vb, seller_address, seller_outpoint, seller_pubkey_hex, expires_at_unix, thumbnail_lines, hide_inscription_ids, .. } = output { + if let CommandOutput::OfferCreate { + inscription, + ask_sats, + fee_rate_sat_vb, + seller_address, + seller_outpoint, + seller_pubkey_hex, + expires_at_unix, + thumbnail_lines, + hide_inscription_ids, + .. + } = output + { let mut out = String::new(); if let Some(lines) = thumbnail_lines { for line in lines { @@ -473,12 +530,24 @@ impl HumanPresenter { if *hide_inscription_ids { lines.push("inscription: [thumbnail shown above]".to_string()); } else { - lines.push(format!("inscription: {}", crate::commands::offer::abbreviate(inscription, 12, 8))); + lines.push(format!( + "inscription: {}", + crate::commands::offer::abbreviate(inscription, 12, 8) + )); } - lines.push(format!("ask: {} sats @ {} sat/vB", ask_sats, fee_rate_sat_vb)); - lines.push(format!("seller input: {}", crate::commands::offer::abbreviate(seller_address, 12, 8))); - lines.push(format!("outpoint: {}", crate::commands::offer::abbreviate(seller_outpoint, 16, 6))); - + lines.push(format!( + "ask: {} sats @ {} sat/vB", + ask_sats, fee_rate_sat_vb + )); + lines.push(format!( + "seller input: {}", + crate::commands::offer::abbreviate(seller_address, 12, 8) + )); + lines.push(format!( + "outpoint: {}", + crate::commands::offer::abbreviate(seller_outpoint, 16, 6) + )); + lines.push(format!( "seller pubkey: {}", crate::commands::offer::abbreviate(seller_pubkey_hex, 10, 6) @@ -492,16 +561,32 @@ impl HumanPresenter { } fn print_offer_publish(&self, output: &CommandOutput) -> String { - if let CommandOutput::OfferPublish { event_id, accepted_relays, total_relays, publish_results, .. } = output { + if let CommandOutput::OfferPublish { + event_id, + accepted_relays, + total_relays, + publish_results, + .. + } = output + { let mut out = String::new(); let mut lines = vec![ "OFFER PUBLISH".to_string(), - format!("event: {}", crate::commands::offer::abbreviate(event_id, 12, 8)), + format!( + "event: {}", + crate::commands::offer::abbreviate(event_id, 12, 8) + ), format!("accepted relays: {accepted_relays}/{total_relays}"), ]; for result in publish_results.iter().take(3) { - let relay = result.get("relay_url").and_then(serde_json::Value::as_str).unwrap_or("-"); - let accepted = result.get("accepted").and_then(serde_json::Value::as_bool).unwrap_or(false); + let relay = result + .get("relay_url") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let accepted = result + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); let status = if accepted { "ok" } else { "reject" }; lines.push(format!("{status}: {relay}")); } @@ -516,7 +601,15 @@ impl HumanPresenter { } fn print_offer_discover(&self, output: &CommandOutput) -> String { - if let CommandOutput::OfferDiscover { event_count, offer_count, offers, thumbnail_lines, hide_inscription_ids, .. } = output { + if let CommandOutput::OfferDiscover { + event_count, + offer_count, + offers, + thumbnail_lines, + hide_inscription_ids, + .. + } = output + { let mut out = String::new(); if let Some(lines) = thumbnail_lines { for line in lines { @@ -528,11 +621,23 @@ impl HumanPresenter { format!("decoded offers: {offer_count} (events: {event_count})"), ]; for (idx, entry) in offers.iter().take(8).enumerate() { - let event_id = entry.get("event_id").and_then(serde_json::Value::as_str).unwrap_or("-"); + let event_id = entry + .get("event_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); let offer = entry.get("offer").and_then(serde_json::Value::as_object); - let inscription = offer.and_then(|o| o.get("inscription_id")).and_then(serde_json::Value::as_str).unwrap_or("-"); - let ask_sats = offer.and_then(|o| o.get("ask_sats")).and_then(serde_json::Value::as_u64).unwrap_or(0); - let seller = offer.and_then(|o| o.get("seller_pubkey_hex")).and_then(serde_json::Value::as_str).unwrap_or("-"); + let inscription = offer + .and_then(|o| o.get("inscription_id")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let ask_sats = offer + .and_then(|o| o.get("ask_sats")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let seller = offer + .and_then(|o| o.get("seller_pubkey_hex")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); if *hide_inscription_ids { lines.push(format!( @@ -572,7 +677,13 @@ impl HumanPresenter { } fn print_offer_list_ord(&self, output: &CommandOutput) -> String { - if let CommandOutput::OfferListOrd { ord_url, count, offers, .. } = output { + if let CommandOutput::OfferListOrd { + ord_url, + count, + offers, + .. + } = output + { let mut out = String::new(); let mut lines = vec![ "OFFER LIST-ORD".to_string(), @@ -581,7 +692,11 @@ impl HumanPresenter { ]; for (idx, psbt) in offers.iter().take(3).enumerate() { if let Some(psbt_str) = psbt.as_str() { - lines.push(format!("{:>2}. {}", idx + 1, crate::commands::offer::abbreviate(psbt_str, 14, 8))); + lines.push(format!( + "{:>2}. {}", + idx + 1, + crate::commands::offer::abbreviate(psbt_str, 14, 8) + )); } } out.push_str(&format!("{}\n", lines.join("\n"))); @@ -592,7 +707,17 @@ impl HumanPresenter { } fn print_offer_accept(&self, output: &CommandOutput) -> String { - if let CommandOutput::OfferAccept { inscription, ask_sats, txid, dry_run, inscription_risk, thumbnail_lines, hide_inscription_ids, .. } = output { + if let CommandOutput::OfferAccept { + inscription, + ask_sats, + txid, + dry_run, + inscription_risk, + thumbnail_lines, + hide_inscription_ids, + .. + } = output + { let mut out = String::new(); if let Some(lines) = thumbnail_lines { for line in lines { @@ -603,13 +728,22 @@ impl HumanPresenter { if *hide_inscription_ids { lines.push("inscription: [thumbnail shown above]".to_string()); } else { - lines.push(format!("inscription: {}", crate::commands::offer::abbreviate(inscription, 12, 8))); + lines.push(format!( + "inscription: {}", + crate::commands::offer::abbreviate(inscription, 12, 8) + )); } lines.push(format!("ask: {ask_sats} sats")); - lines.push(format!("mode: {}", if *dry_run { "dry-run" } else { "broadcast" })); + lines.push(format!( + "mode: {}", + if *dry_run { "dry-run" } else { "broadcast" } + )); lines.push(format!("inscription risk: {inscription_risk}")); if txid != "-" { - lines.push(format!("txid: {}", crate::commands::offer::abbreviate(txid, 12, 8))); + lines.push(format!( + "txid: {}", + crate::commands::offer::abbreviate(txid, 12, 8) + )); } out.push_str(&format!("{}\n", lines.join("\n"))); out @@ -623,43 +757,110 @@ impl Presenter for HumanPresenter { fn render(&self, output: &CommandOutput) -> String { use console::style; match output { - CommandOutput::WalletInfo { profile, network, scheme, account_index, esplora_url, ord_url, has_persistence, has_inscriptions, updated_at_unix, .. } => { + CommandOutput::WalletInfo { + profile, + network, + scheme, + account_index, + esplora_url, + ord_url, + has_persistence, + has_inscriptions, + updated_at_unix, + .. + } => { let mut out = String::new(); - out.push_str(&format!(" {:<12} {}\n", style("Profile").dim(), profile.as_deref().unwrap_or("default"))); + out.push_str(&format!( + " {:<12} {}\n", + style("Profile").dim(), + profile.as_deref().unwrap_or("default") + )); out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network)); out.push_str(&format!(" {:<12} {}\n", style("Scheme").dim(), scheme)); - out.push_str(&format!(" {:<12} {}\n", style("Account").dim(), account_index)); - out.push_str(&format!(" {:<12} {}\n", style("Esplora").dim(), esplora_url)); + out.push_str(&format!( + " {:<12} {}\n", + style("Account").dim(), + account_index + )); + out.push_str(&format!( + " {:<12} {}\n", + style("Esplora").dim(), + esplora_url + )); out.push_str(&format!(" {:<12} {}\n", style("Ord").dim(), ord_url)); - + let check = style("✓").green(); let dash = style("-").dim(); - out.push_str(&format!(" {:<12} {}\n", style("Storage").dim(), if *has_persistence { &check } else { &dash })); - out.push_str(&format!(" {:<12} {}\n", style("Inscriptions").dim(), if *has_inscriptions { &check } else { &dash })); + out.push_str(&format!( + " {:<12} {}\n", + style("Storage").dim(), + if *has_persistence { &check } else { &dash } + )); + out.push_str(&format!( + " {:<12} {}\n", + style("Inscriptions").dim(), + if *has_inscriptions { &check } else { &dash } + )); let time_str = { - let d = std::time::UNIX_EPOCH + std::time::Duration::from_secs(*updated_at_unix); + let d = + std::time::UNIX_EPOCH + std::time::Duration::from_secs(*updated_at_unix); let datetime: chrono::DateTime = d.into(); datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string() }; out.push_str(&format!(" {:<12} {}\n", style("Updated").dim(), time_str)); out } - CommandOutput::WalletInit { profile, network, phrase, words, .. } => { + CommandOutput::WalletInit { + profile, + network, + phrase, + words, + .. + } => { let mut out = format!("{} {}\n", style("✓").green().bold(), "Wallet initialized"); - out.push_str(&format!(" {:<12} {}\n", style("Profile").dim(), profile.as_deref().unwrap_or("default"))); + out.push_str(&format!( + " {:<12} {}\n", + style("Profile").dim(), + profile.as_deref().unwrap_or("default") + )); out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network)); - out.push_str(&format!(" {:<12} {}\n", style("Phrase").dim(), if phrase.contains(" { + CommandOutput::WalletImport { + profile, + network, + phrase, + .. + } => { let mut out = format!("{} {}\n", style("✓").green().bold(), "Wallet imported"); - out.push_str(&format!(" {:<12} {}\n", style("Profile").dim(), profile.as_deref().unwrap_or("default"))); + out.push_str(&format!( + " {:<12} {}\n", + style("Profile").dim(), + profile.as_deref().unwrap_or("default") + )); out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network)); if let Some(p) = phrase { - out.push_str(&format!(" {:<12} {}\n", style("Phrase").dim(), if p.contains(" { + CommandOutput::Balance { + total, + spendable, + inscribed_sats, + } => { let mut table = comfy_table::Table::new(); table.set_header(vec![ comfy_table::Cell::new("Type").fg(comfy_table::Color::Cyan), comfy_table::Cell::new("Confirmed (sats)").fg(comfy_table::Color::Green), comfy_table::Cell::new("Trusted Pending (sats)").fg(comfy_table::Color::Yellow), - comfy_table::Cell::new("Untrusted Pending (sats)").fg(comfy_table::Color::Magenta), + comfy_table::Cell::new("Untrusted Pending (sats)") + .fg(comfy_table::Color::Magenta), ]); table.add_row(vec![ comfy_table::Cell::new("Total (Combined)"), @@ -722,10 +931,23 @@ impl Presenter for HumanPresenter { } format!("{table}") } - CommandOutput::AccountUse { account_index, taproot_address, payment_address, .. } => { + CommandOutput::AccountUse { + account_index, + taproot_address, + payment_address, + .. + } => { let mut out = format!("{} {}\n", style("✓").green().bold(), "Switched account"); - out.push_str(&format!(" {:<12} {}\n", style("Index").dim(), account_index)); - out.push_str(&format!(" {:<12} {}\n", style("Taproot").dim(), taproot_address)); + out.push_str(&format!( + " {:<12} {}\n", + style("Index").dim(), + account_index + )); + out.push_str(&format!( + " {:<12} {}\n", + style("Taproot").dim(), + taproot_address + )); if let Some(payment) = payment_address { out.push_str(&format!(" {:<12} {}\n", style("Payment").dim(), payment)); } @@ -779,10 +1001,23 @@ impl Presenter for HumanPresenter { out.push_str(&format!(" {:<12} {}\n", style("PSBT").dim(), psbt)); out } - CommandOutput::PsbtAnalyze { safe_to_send, inscription_risk, policy_reasons, .. } => { + CommandOutput::PsbtAnalyze { + safe_to_send, + inscription_risk, + policy_reasons, + .. + } => { let mut out = format!("{} {}\n", style("ℹ").blue().bold(), "PSBT Analysis"); - let risk_style = if *safe_to_send { style(inscription_risk.as_str()).green() } else { style(inscription_risk.as_str()).red() }; - out.push_str(&format!(" {:<12} {}\n", style("Safe").dim(), if *safe_to_send { "yes" } else { "no" })); + let risk_style = if *safe_to_send { + style(inscription_risk.as_str()).green() + } else { + style(inscription_risk.as_str()).red() + }; + out.push_str(&format!( + " {:<12} {}\n", + style("Safe").dim(), + if *safe_to_send { "yes" } else { "no" } + )); out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style)); if !policy_reasons.is_empty() { out.push_str(&format!(" {:<12}\n", style("Reasons").dim())); @@ -792,39 +1027,99 @@ impl Presenter for HumanPresenter { } out } - CommandOutput::PsbtSign { psbt, safe_to_send, inscription_risk, .. } => { + CommandOutput::PsbtSign { + psbt, + safe_to_send, + inscription_risk, + .. + } => { let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Signed"); - let risk_style = if *safe_to_send { style(inscription_risk.as_str()).green() } else { style(inscription_risk.as_str()).red() }; - out.push_str(&format!(" {:<12} {}\n", style("Safe").dim(), if *safe_to_send { "yes" } else { "no" })); + let risk_style = if *safe_to_send { + style(inscription_risk.as_str()).green() + } else { + style(inscription_risk.as_str()).red() + }; + out.push_str(&format!( + " {:<12} {}\n", + style("Safe").dim(), + if *safe_to_send { "yes" } else { "no" } + )); out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style)); out.push_str(&format!(" {:<12} {}\n", style("PSBT").dim(), psbt)); out } - CommandOutput::PsbtBroadcast { txid, safe_to_send, inscription_risk, .. } => { + CommandOutput::PsbtBroadcast { + txid, + safe_to_send, + inscription_risk, + .. + } => { let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Broadcasted"); - let risk_style = if *safe_to_send { style(inscription_risk.as_str()).green() } else { style(inscription_risk.as_str()).red() }; - out.push_str(&format!(" {:<12} {}\n", style("Safe").dim(), if *safe_to_send { "yes" } else { "no" })); + let risk_style = if *safe_to_send { + style(inscription_risk.as_str()).green() + } else { + style(inscription_risk.as_str()).red() + }; + out.push_str(&format!( + " {:<12} {}\n", + style("Safe").dim(), + if *safe_to_send { "yes" } else { "no" } + )); out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style)); out.push_str(&format!(" {:<12} {}\n", style("TxID").dim(), txid)); out } CommandOutput::SyncChain { events } => { - format!("{} Synced {} events\n", style("✓").green().bold(), events.len()) + format!( + "{} Synced {} events\n", + style("✓").green().bold(), + events.len() + ) } CommandOutput::SyncOrdinals { inscriptions } => { - format!("{} Synced {} inscriptions\n", style("✓").green().bold(), inscriptions) + format!( + "{} Synced {} inscriptions\n", + style("✓").green().bold(), + inscriptions + ) } - CommandOutput::WaitTxConfirmed { txid, waited_secs, .. } => { - format!("{} Tx {} confirmed after {}s\n", style("✓").green().bold(), txid, waited_secs) + CommandOutput::WaitTxConfirmed { + txid, waited_secs, .. + } => { + format!( + "{} Tx {} confirmed after {}s\n", + style("✓").green().bold(), + txid, + waited_secs + ) } - CommandOutput::WaitBalance { confirmed_balance, target, waited_secs, .. } => { - format!("{} Target balance {} reached (current: {}) after {}s\n", style("✓").green().bold(), target, confirmed_balance, waited_secs) + CommandOutput::WaitBalance { + confirmed_balance, + target, + waited_secs, + .. + } => { + format!( + "{} Target balance {} reached (current: {}) after {}s\n", + style("✓").green().bold(), + target, + confirmed_balance, + waited_secs + ) } CommandOutput::SnapshotSave { snapshot } => { - format!("{} Saved snapshot to {}\n", style("✓").green().bold(), snapshot) + format!( + "{} Saved snapshot to {}\n", + style("✓").green().bold(), + snapshot + ) } CommandOutput::SnapshotRestore { restored } => { - format!("{} Restored snapshot from {}\n", style("✓").green().bold(), restored) + format!( + "{} Restored snapshot from {}\n", + style("✓").green().bold(), + restored + ) } CommandOutput::SnapshotList { snapshots } => { let mut table = comfy_table::Table::new(); @@ -839,7 +1134,10 @@ impl Presenter for HumanPresenter { .file_stem() .and_then(|s| s.to_str()) .unwrap_or(path_str); - table.add_row(vec![comfy_table::Cell::new(name), comfy_table::Cell::new(path_str)]); + table.add_row(vec![ + comfy_table::Cell::new(name), + comfy_table::Cell::new(path_str), + ]); } format!("{table}") } @@ -859,7 +1157,10 @@ impl Presenter for HumanPresenter { Value::Null => "-".to_string(), _ => v.to_string(), }; - table.add_row(vec![comfy_table::Cell::new(k), comfy_table::Cell::new(val_str)]); + table.add_row(vec![ + comfy_table::Cell::new(k), + comfy_table::Cell::new(val_str), + ]); } } format!("{table}") @@ -874,9 +1175,19 @@ impl Presenter for HumanPresenter { format!("{} {} was not set\n", style("ℹ").blue().bold(), key) } } - CommandOutput::LockInfo { lock_path, locked, owner_pid, age_secs, .. } => { + CommandOutput::LockInfo { + lock_path, + locked, + owner_pid, + age_secs, + .. + } => { let mut out = format!("{} {}\n", style("ℹ").blue().bold(), "Lock Status"); - let status = if *locked { style("Locked").red() } else { style("Unlocked").green() }; + let status = if *locked { + style("Locked").red() + } else { + style("Unlocked").green() + }; out.push_str(&format!(" {:<12} {}\n", style("Status").dim(), status)); out.push_str(&format!(" {:<12} {}\n", style("Path").dim(), lock_path)); if let Some(pid) = owner_pid { @@ -887,30 +1198,78 @@ impl Presenter for HumanPresenter { } out } - CommandOutput::LockClear { lock_path, cleared, .. } => { + CommandOutput::LockClear { + lock_path, cleared, .. + } => { if *cleared { - format!("{} Cleared lock at {}\n", style("✓").green().bold(), lock_path) + format!( + "{} Cleared lock at {}\n", + style("✓").green().bold(), + lock_path + ) } else { - format!("{} No lock to clear at {}\n", style("ℹ").blue().bold(), lock_path) + format!( + "{} No lock to clear at {}\n", + style("ℹ").blue().bold(), + lock_path + ) } } CommandOutput::Doctor { .. } => self.print_doctor(output), - CommandOutput::Setup { config_saved, wizard_used, profile, default_network, default_scheme, wallet_initialized, wallet_mode, wallet_phrase, .. } => { + CommandOutput::Setup { + config_saved, + wizard_used, + profile, + default_network, + default_scheme, + wallet_initialized, + wallet_mode, + wallet_phrase, + .. + } => { let mut out = String::new(); out.push_str(&format!("{} Setup complete\n", style("✓").green().bold())); - out.push_str(&format!(" {:<15} {}\n", style("Config Saved:").dim(), config_saved)); - out.push_str(&format!(" {:<15} {}\n", style("Wizard Used:").dim(), wizard_used)); - out.push_str(&format!(" {:<15} {}\n", style("Profile:").dim(), profile.as_deref().unwrap_or("default"))); - out.push_str(&format!(" {:<15} {}\n", style("Network:").dim(), default_network)); - out.push_str(&format!(" {:<15} {}\n", style("Scheme:").dim(), default_scheme)); + out.push_str(&format!( + " {:<15} {}\n", + style("Config Saved:").dim(), + config_saved + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Wizard Used:").dim(), + wizard_used + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Profile:").dim(), + profile.as_deref().unwrap_or("default") + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Network:").dim(), + default_network + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Scheme:").dim(), + default_scheme + )); if *wallet_initialized { - out.push_str(&format!(" {:<15} {}\n", style("Wallet:").dim(), "Initialized")); + out.push_str(&format!( + " {:<15} {}\n", + style("Wallet:").dim(), + "Initialized" + )); if let Some(mode) = wallet_mode { out.push_str(&format!(" {:<15} {}\n", style("Mode:").dim(), mode)); } if let Some(phrase) = wallet_phrase { if phrase != "" { - out.push_str(&format!("\n{}\n{}\n", style("Mnemonic Phrase (keep this safe!):").red().bold(), phrase)); + out.push_str(&format!( + "\n{}\n{}\n", + style("Mnemonic Phrase (keep this safe!):").red().bold(), + phrase + )); } else { out.push_str(&format!(" {:<15} {}\n", style("Phrase:").dim(), phrase)); } @@ -918,11 +1277,38 @@ impl Presenter for HumanPresenter { } out } - CommandOutput::ScenarioMine { blocks, address, raw_output } => { - format!("{} Mined {} blocks to {}\nOutput:\n{}\n", style("✓").green().bold(), blocks, address, raw_output) + CommandOutput::ScenarioMine { + blocks, + address, + raw_output, + } => { + format!( + "{} Mined {} blocks to {}\nOutput:\n{}\n", + style("✓").green().bold(), + blocks, + address, + raw_output + ) } - CommandOutput::ScenarioFund { address, amount_btc, txid, mine_blocks, mine_address, generated_blocks } => { - format!("{} Funded {} with {} BTC\nTxID: {}\n{} Mined {} blocks to {}\nOutput:\n{}\n", style("✓").green().bold(), address, amount_btc, txid, style("✓").green().bold(), mine_blocks, mine_address, generated_blocks) + CommandOutput::ScenarioFund { + address, + amount_btc, + txid, + mine_blocks, + mine_address, + generated_blocks, + } => { + format!( + "{} Funded {} with {} BTC\nTxID: {}\n{} Mined {} blocks to {}\nOutput:\n{}\n", + style("✓").green().bold(), + address, + amount_btc, + txid, + style("✓").green().bold(), + mine_blocks, + mine_address, + generated_blocks + ) } CommandOutput::ScenarioReset { removed } => { let mut out = format!("{} Scenario Reset\n", style("✓").green().bold()); @@ -931,7 +1317,11 @@ impl Presenter for HumanPresenter { } out } - CommandOutput::InscriptionList { inscriptions, display_items, thumb_mode_enabled } => self.print_inscription_list(inscriptions, display_items, *thumb_mode_enabled), + CommandOutput::InscriptionList { + inscriptions, + display_items, + thumb_mode_enabled, + } => self.print_inscription_list(inscriptions, display_items, *thumb_mode_enabled), CommandOutput::OfferCreate { .. } => self.print_offer_create(output), CommandOutput::OfferPublish { .. } => self.print_offer_publish(output), CommandOutput::OfferDiscover { .. } => self.print_offer_discover(output), diff --git a/src/paths.rs b/src/paths.rs index 7f0f27c..e9be32c 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -25,8 +25,8 @@ pub fn write_secure_file>(path: P, contents: &[u8]) -> std::io::R let path = path.as_ref(); #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt; use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; let mut options = fs::OpenOptions::new(); options.write(true).create(true).truncate(true).mode(0o600); let mut file = options.open(path)?; diff --git a/src/presenter/grid.rs b/src/presenter/grid.rs index 5ce042f..05a789a 100644 --- a/src/presenter/grid.rs +++ b/src/presenter/grid.rs @@ -73,11 +73,7 @@ pub fn render_grid(cards: &[GridCard], max_cols: usize, gutter: usize) -> String for line_idx in 0..max_height { for (card_idx, card) in row_cards.iter().enumerate() { - let line = card - .lines - .get(line_idx) - .map(|s| s.as_str()) - .unwrap_or(""); + let line = card.lines.get(line_idx).map(|s| s.as_str()).unwrap_or(""); // Pad every card to cell_width so columns stay aligned. out.push_str(&pad_to_visible(line, cell_width)); @@ -119,9 +115,15 @@ mod tests { #[test] fn grid_arranges_cards_side_by_side() { let cards = vec![ - GridCard { lines: vec!["AAAA".into(), "AAAA".into()] }, - GridCard { lines: vec!["BBBB".into(), "BBBB".into()] }, - GridCard { lines: vec!["CCCC".into()] }, // shorter card + GridCard { + lines: vec!["AAAA".into(), "AAAA".into()], + }, + GridCard { + lines: vec!["BBBB".into(), "BBBB".into()], + }, + GridCard { + lines: vec!["CCCC".into()], + }, // shorter card ]; let output = render_grid(&cards, 20, 2); // With cell_width=4, gutter=2, cols_per_row = (20+2)/(4+2) = 3 @@ -138,9 +140,15 @@ mod tests { #[test] fn grid_wraps_to_multiple_rows() { let cards = vec![ - GridCard { lines: vec!["AAAA".into()] }, - GridCard { lines: vec!["BBBB".into()] }, - GridCard { lines: vec!["CCCC".into()] }, + GridCard { + lines: vec!["AAAA".into()], + }, + GridCard { + lines: vec!["BBBB".into()], + }, + GridCard { + lines: vec!["CCCC".into()], + }, ]; // max_cols=10, gutter=2 → cell_width=4, cols_per_row = (10+2)/(4+2) = 2 let output = render_grid(&cards, 10, 2); diff --git a/src/utils.rs b/src/utils.rs index 53162a4..56a5b07 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -104,6 +104,19 @@ pub fn run_bitcoin_cli( profile: &Profile, args: &[String], ) -> Result { + // SECURITY: Prevent arbitrary command execution. + // Ensure the configured binary is exactly bitcoin-cli (or .exe). + // The user can specify a path (e.g., /usr/local/bin/bitcoin-cli), but the filename must match. + let bin_path = std::path::Path::new(&profile.bitcoin_cli); + let file_name = bin_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if file_name != "bitcoin-cli" && file_name != "bitcoin-cli.exe" { + return Err(crate::error::AppError::Config(format!( + "Security violation: configured bitcoin_cli must be 'bitcoin-cli' or 'bitcoin-cli.exe', found: '{}'", + file_name + ))); + } + let mut cmd = std::process::Command::new(&profile.bitcoin_cli); for arg in &profile.bitcoin_cli_args { cmd.arg(arg); diff --git a/src/wallet_service.rs b/src/wallet_service.rs index c41aa76..3b96725 100644 --- a/src/wallet_service.rs +++ b/src/wallet_service.rs @@ -6,7 +6,6 @@ use rpassword::read_password; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use std::process::Command as ShellCommand; pub use zinc_core::{ decrypt_wallet_internal, encrypt_wallet_internal, generate_wallet_internal, @@ -190,26 +189,5 @@ pub fn persist_wallet_session(session: &mut WalletSession) -> Result<(), AppErro write_profile(&session.profile_path, &session.profile) } -#[allow(dead_code)] -pub fn run_bitcoin_cli(profile: &Profile, args: &[String]) -> Result { - let output = ShellCommand::new(&profile.bitcoin_cli) - .args(&profile.bitcoin_cli_args) - .args(args) - .output() - .map_err(|e| AppError::Config(format!("failed to launch {}: {e}", profile.bitcoin_cli)))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let details = if !stderr.is_empty() { stderr } else { stdout }; - return Err(AppError::Network(format!( - "bitcoin-cli command failed: {} {}", - profile.bitcoin_cli, details - ))); - } - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - #[cfg(test)] mod tests {} diff --git a/src/wizard/mod.rs b/src/wizard/mod.rs index da8b954..f5dc6d9 100644 --- a/src/wizard/mod.rs +++ b/src/wizard/mod.rs @@ -40,7 +40,6 @@ pub(crate) fn resolve_setup_values(cli: &Cli, args: &SetupArgs) -> Result *value, None => cli.quiet, diff --git a/tests/contract_v1.rs b/tests/contract_v1.rs index 0d1fd5a..70214c2 100644 --- a/tests/contract_v1.rs +++ b/tests/contract_v1.rs @@ -608,7 +608,9 @@ fn test_unknown_command_has_suggestion() { #[test] fn test_unknown_global_flag_has_suggestion() { let mut cmd = cargo_cmd(); - cmd.args(&["run", "--quiet", "--", "--agent", "--jons", "wallet", "info"]); + cmd.args(&[ + "run", "--quiet", "--", "--agent", "--jons", "wallet", "info", + ]); let output = cmd.output().expect("failed to execute process"); assert!(!output.status.success());