Skip to main content

bhwi_cli/
lib.rs

1//! Native command-line device selection, output helpers, and Python HWI compatibility.
2//!
3//! Address and descriptor helpers print to stdout; [`hwi::process_request`] returns
4//! a serializable response. Selection warnings and device-interaction prompts use stderr.
5//! On Unix, `hwi`'s `--stdinpass` password prompt uses the controlling terminal when
6//! available, falling back to a stderr prompt and stdin input if password reading fails.
7//!
8//! Native discovery types and shared selection helpers are reexported here;
9//! Linux builds also reexport udev-rule installation through `udev`.
10
11use std::{ops::Deref, sync::Arc};
12
13#[cfg(target_os = "linux")]
14pub use bhwi_async_transport::udev;
15pub use bhwi_async_transport::{
16    Device, DeviceSelector, DeviceType, Info, NativeError, NativeSource, SelectError,
17    SkippedDevice, can_sign_taproot, is_user_cancelled, networks_string, no_device,
18    reports_device_info,
19};
20use bitcoin::{Network, bip32::Fingerprint};
21use clap::ValueEnum;
22use serde::{Serialize, Serializer};
23
24/// A native device manager with CLI pairing and host-interaction prompts.
25///
26/// Remembers whether `-p` was given, since the selector only keeps the password
27/// when Trezor or KeepKey is built in.
28pub struct DeviceManager {
29    manager: bhwi_async_transport::DeviceManager<NativeSource>,
30    password_given: bool,
31}
32
33impl Deref for DeviceManager {
34    type Target = bhwi_async_transport::DeviceManager<NativeSource>;
35
36    fn deref(&self) -> &Self::Target {
37        &self.manager
38    }
39}
40
41impl DeviceManager {
42    /// Returns whether a password argument was supplied, including an empty one.
43    pub fn password_given(&self) -> bool {
44        self.password_given
45    }
46
47    /// Rejects a supplied host password for BitBox02, which requires on-device entry.
48    pub fn refuse_password(&self, device_type: DeviceType) -> Result<(), SelectError<NativeError>> {
49        #[cfg(feature = "bitbox")]
50        if self.password_given && device_type == DeviceType::BitBox02 {
51            return Err(SelectError::HostPassphraseRejected);
52        }
53        let _ = device_type;
54        Ok(())
55    }
56}
57
58/// The `bhwi` frontend's serializable view of cached device metadata.
59///
60/// Conversion from [`Device`] performs no I/O. The device family uses the
61/// `device_type` key, unlike [`hwi::HwiEnumeratedDevice`]'s `type` key.
62/// Uncached info is omitted and an uncached fingerprint is `null`. Absent labels
63/// are omitted; missing firmware is `null`, except Specter omits firmware and
64/// version keys altogether.
65#[derive(Serialize)]
66pub struct DeviceJson<'a> {
67    name: &'a str,
68    #[serde(serialize_with = "serialize_device_type")]
69    device_type: DeviceType,
70    path: &'a str,
71    model: &'a str,
72    is_emulated: bool,
73    fingerprint: Option<Fingerprint>,
74    #[serde(flatten)]
75    info: Option<InfoJson<'a>>,
76}
77
78/// The outer `None` omits a key, `Some(None)` writes `null`.
79#[derive(Serialize)]
80struct InfoJson<'a> {
81    #[serde(skip_serializing_if = "Option::is_none")]
82    version: Option<&'a str>,
83    networks: &'a [Network],
84    #[serde(skip_serializing_if = "Option::is_none")]
85    firmware: Option<Option<&'a str>>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    label: Option<&'a str>,
88}
89
90impl<'a> InfoJson<'a> {
91    fn new(device_type: DeviceType, info: &'a Info) -> Self {
92        // Specter-DIY reports no firmware, so it has neither key.
93        let reports_firmware = device_type != DeviceType::Specter;
94        Self {
95            version: reports_firmware.then_some(info.version.as_str()),
96            networks: &info.networks,
97            firmware: reports_firmware.then_some(info.firmware.as_deref()),
98            label: info.label.as_deref(),
99        }
100    }
101}
102
103impl<'a> From<&'a Device> for DeviceJson<'a> {
104    fn from(device: &'a Device) -> Self {
105        Self {
106            name: device.name(),
107            device_type: device.device_type(),
108            path: device.path(),
109            model: device.model(),
110            is_emulated: device.is_emulated(),
111            fingerprint: device.cached_fingerprint(),
112            info: device
113                .cached_info()
114                .map(|info| InfoJson::new(device.device_type(), info)),
115        }
116    }
117}
118
119fn serialize_device_type<S>(device_type: &DeviceType, ser: S) -> Result<S::Ok, S::Error>
120where
121    S: Serializer,
122{
123    ser.serialize_str(device_type.as_str())
124}
125
126/// Creates a native manager with stderr pairing prompts and CLI host interaction.
127///
128/// `password_given` records whether a password argument was supplied, even if empty.
129pub fn device_manager(selector: DeviceSelector, password_given: bool) -> DeviceManager {
130    manager_over(NativeSource::default(), selector, password_given)
131}
132
133/// Creates a CLI manager that excludes Specter-DIY from broad HWI discovery.
134///
135/// Python HWI has no Specter-DIY compatibility contract. The exclusion does not
136/// prevent explicitly selecting a Specter device.
137pub fn python_hwi_device_manager(selector: DeviceSelector, password_given: bool) -> DeviceManager {
138    manager_over(
139        NativeSource::excluding([DeviceType::Specter]),
140        selector,
141        password_given,
142    )
143}
144
145fn manager_over(
146    source: NativeSource,
147    selector: DeviceSelector,
148    password_given: bool,
149) -> DeviceManager {
150    let manager = bhwi_async_transport::DeviceManager::new(source, selector)
151        .with_pairing_code_prompt(Arc::new(|code| {
152            eprintln!("\nBitBox02 pairing code — confirm on device:\n\n{code}\n");
153        }));
154    #[cfg(feature = "keepkey")]
155    let manager = manager.with_host_interaction(host::cli_host_interaction());
156    DeviceManager {
157        manager,
158        password_given,
159    }
160}
161
162/// Prints a skipped-device warning to stderr.
163pub fn warn_skipped(entry: &SkippedDevice) {
164    eprintln!(
165        "Warning: skipping {} at {}: {}",
166        entry.device_type, entry.path, entry.error
167    );
168}
169
170/// Selects a device and prints skipped-device and network-mismatch warnings to stderr.
171///
172/// Returns `None` when no device and no skipped candidate are found. If selection
173/// finds only skipped candidates, returns the first candidate's error. A network
174/// mismatch warns rather than rejecting the device; querying the network may
175/// perform I/O. The `hwi` frontend deliberately does not use this warning path.
176pub async fn select_device(manager: &DeviceManager) -> anyhow::Result<Option<Device>> {
177    let (device, skipped) = manager.select().await?;
178    let Some(mut device) = device else {
179        let Some(first) = skipped.first() else {
180            return Ok(None);
181        };
182        anyhow::bail!("{}", first.error);
183    };
184    manager.refuse_password(device.device_type())?;
185    for entry in &skipped {
186        warn_skipped(entry);
187    }
188    if let Some(mismatch) = device.network_mismatch(manager.selector.network).await? {
189        eprintln!(
190            "Warning: device {} is on {}, expected {}",
191            device.name(),
192            networks_string(&mismatch.reported),
193            mismatch.expected
194        );
195    }
196    Ok(Some(device))
197}
198
199pub mod address;
200pub mod get_descriptors;
201#[cfg(feature = "keepkey")]
202pub mod host;
203pub mod hwi;
204pub mod management;
205
206/// An output format for CLI tables and descriptor records.
207#[derive(Debug, Clone, Copy, ValueEnum)]
208pub enum OutputFormat {
209    /// Human-readable tabular output.
210    Pretty,
211    /// Serialized JSON output.
212    Json,
213}
214
215/// A device-family argument accepted by Clap, independent of compiled device support.
216#[derive(Debug, Clone, Copy, ValueEnum)]
217pub enum DeviceTypeArg {
218    /// A BitBox02, accepted as `bitbox02` or `bit-box02`.
219    #[value(name = "bitbox02", alias = "bit-box02")]
220    BitBox02,
221    /// A Coldcard.
222    Coldcard,
223    /// A Blockstream Jade.
224    Jade,
225    /// A KeepKey, accepted as `keepkey` or `keep-key`.
226    #[value(name = "keepkey", alias = "keep-key")]
227    KeepKey,
228    /// A Ledger.
229    Ledger,
230    /// A Specter-DIY.
231    Specter,
232    /// A Trezor.
233    Trezor,
234}
235
236impl From<DeviceTypeArg> for DeviceType {
237    fn from(arg: DeviceTypeArg) -> Self {
238        match arg {
239            DeviceTypeArg::BitBox02 => DeviceType::BitBox02,
240            DeviceTypeArg::Coldcard => DeviceType::Coldcard,
241            DeviceTypeArg::Jade => DeviceType::Jade,
242            DeviceTypeArg::KeepKey => DeviceType::KeepKey,
243            DeviceTypeArg::Ledger => DeviceType::Ledger,
244            DeviceTypeArg::Specter => DeviceType::Specter,
245            DeviceTypeArg::Trezor => DeviceType::Trezor,
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn a_given_password_is_refused_only_for_bitbox02() {
256        let given = device_manager(DeviceSelector::default(), true);
257        let not_given = device_manager(DeviceSelector::default(), false);
258        for device_type in DeviceType::ALL {
259            assert_eq!(
260                given.refuse_password(device_type).is_err(),
261                cfg!(feature = "bitbox") && device_type == DeviceType::BitBox02,
262                "{device_type}"
263            );
264            assert!(not_given.refuse_password(device_type).is_ok());
265        }
266    }
267
268    #[test]
269    fn ordinary_info_keeps_the_firmware_null_json_contract() {
270        let info = Info::default();
271        let value = serde_json::to_value(InfoJson::new(DeviceType::Ledger, &info)).unwrap();
272        assert!(value.get("version").is_some());
273        assert!(value.get("firmware").is_some_and(|value| value.is_null()));
274    }
275
276    #[test]
277    fn specter_info_omits_unavailable_firmware_from_json() {
278        let info = Info {
279            version: "unavailable".into(),
280            ..Info::default()
281        };
282        let value = serde_json::to_value(InfoJson::new(DeviceType::Specter, &info)).unwrap();
283        assert!(value.get("version").is_none());
284        assert!(value.get("firmware").is_none());
285        assert!(value.get("networks").is_some());
286    }
287}