1use std::{
4 cell::Cell,
5 io::{self, IsTerminal, Write},
6 rc::Rc,
7};
8
9use async_trait::async_trait;
10use bhwi::common::{self, HostRequest, HostResponse, PinMatrixRequestKind};
11use bhwi_async::{HostInteraction, device::HostInteractionFactory};
12
13use crate::hwi::PIN_MATRIX_DESCRIPTION;
14
15pub fn cli_host_interaction() -> HostInteractionFactory {
25 Rc::new(|| Box::new(CliHostInteraction))
26}
27
28struct CliHostInteraction;
29
30#[async_trait(?Send)]
31impl HostInteraction for CliHostInteraction {
32 async fn respond(&mut self, request: &HostRequest) -> Result<HostResponse, common::Error> {
33 let terminal = io::stdin().is_terminal();
34 read_host_response(
35 request,
36 || {
37 if terminal {
38 read_hidden_line()
39 } else {
40 let mut response = String::new();
41 let read = io::stdin().read_line(&mut response)?;
42 if read == 0 {
43 Ok(None)
44 } else {
45 response.truncate(response.trim_end_matches(['\r', '\n']).len());
46 Ok(Some(response))
47 }
48 }
49 },
50 |prompt| {
51 eprint!("{prompt}");
52
53 io::stderr().flush()
54 },
55 )
56 }
57}
58struct HiddenOutput {
59 line_completed: Rc<Cell<bool>>,
60}
61
62impl Write for HiddenOutput {
63 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
64 if bytes.contains(&b'\n') {
65 self.line_completed.set(true);
66 }
67 io::stderr().write(bytes)
68 }
69
70 fn flush(&mut self) -> io::Result<()> {
71 io::stderr().flush()
72 }
73}
74
75fn read_hidden_line() -> io::Result<Option<String>> {
76 let line_completed = Rc::new(Cell::new(false));
77 let response = rpassword::read_password_with_config(
78 rpassword::ConfigBuilder::new()
79 .output_writer(HiddenOutput {
80 line_completed: Rc::clone(&line_completed),
81 })
82 .build(),
83 )?;
84 Ok(completed_hidden_line(response, line_completed.get()))
85}
86
87fn completed_hidden_line(response: String, line_completed: bool) -> Option<String> {
88 line_completed.then_some(response)
89}
90
91fn read_host_response(
92 request: &HostRequest,
93 mut read: impl FnMut() -> io::Result<Option<String>>,
94 mut write_prompt: impl FnMut(&str) -> io::Result<()>,
95) -> Result<HostResponse, common::Error> {
96 loop {
97 write_prompt(&host_prompt(request)).map_err(host_io_error)?;
98 let response = read()
99 .map_err(host_io_error)?
100 .ok_or_else(|| common::Error::new(common::ErrorKind::UserCancelled, ""))?;
101 if let Some(response) = parse_host_response(request, response) {
102 return Ok(response);
103 }
104 }
105}
106
107fn host_prompt(request: &HostRequest) -> String {
108 match request {
109 HostRequest::PinMatrix { kind } => match kind {
110 PinMatrixRequestKind::Current => {
111 format!("{PIN_MATRIX_DESCRIPTION}\nEnter current PIN positions:\n")
112 }
113 PinMatrixRequestKind::NewFirst => {
114 format!("{PIN_MATRIX_DESCRIPTION}\nEnter new PIN positions:\n")
115 }
116 PinMatrixRequestKind::NewSecond => {
117 format!("{PIN_MATRIX_DESCRIPTION}\nRe-enter new PIN positions:\n")
118 }
119 PinMatrixRequestKind::Unknown(code) => {
120 format!("{PIN_MATRIX_DESCRIPTION}\nEnter PIN positions for request {code}:\n")
121 }
122 },
123 HostRequest::RecoveryCharacter {
124 word_position,
125 character_position,
126 } => format!(
127 "Recovery word {word_position}, character {character_position} (letter/space/backspace/done):\n"
128 ),
129 }
130}
131
132fn parse_host_response(request: &HostRequest, response: String) -> Option<HostResponse> {
133 match request {
134 HostRequest::PinMatrix { .. }
135 if !response.is_empty() && response.bytes().all(|byte| byte.is_ascii_digit()) =>
136 {
137 Some(HostResponse::PinPositions(response))
138 }
139 HostRequest::RecoveryCharacter { .. } => match response.as_str() {
140 "space" => Some(HostResponse::RecoveryNextWord),
141 "backspace" => Some(HostResponse::RecoveryDelete),
142 "done" => Some(HostResponse::RecoveryDone),
143 _ if response.len() == 1 && response.as_bytes()[0].is_ascii_lowercase() => Some(
144 HostResponse::RecoveryCharacter(response.as_bytes()[0] as char),
145 ),
146 _ => None,
147 },
148 _ => None,
149 }
150}
151
152fn host_io_error(error: io::Error) -> common::Error {
153 if matches!(
154 error.kind(),
155 io::ErrorKind::UnexpectedEof | io::ErrorKind::Interrupted
156 ) {
157 common::Error::new(common::ErrorKind::UserCancelled, "")
158 } else {
159 common::Error::new(
160 common::ErrorKind::Rejected,
161 format!("host interaction failed: {error}"),
162 )
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn pin_input_repeats_until_nonempty_ascii_digits() {
172 let request = HostRequest::PinMatrix {
173 kind: PinMatrixRequestKind::NewFirst,
174 };
175 let mut lines = [Some(""), Some("12"), Some("12a"), Some("7913")].into_iter();
176 let mut prompts = Vec::new();
177 let response = read_host_response(
178 &request,
179 || Ok(lines.next().flatten().map(str::to_owned)),
180 |prompt| {
181 prompts.push(prompt.to_owned());
182 Ok(())
183 },
184 )
185 .unwrap();
186
187 assert_eq!(response, HostResponse::PinPositions("7913".to_owned()));
188 assert_eq!(prompts.len(), 4);
189 assert!(prompts.iter().all(|prompt| prompt.contains("new PIN")));
190 }
191
192 #[test]
193 fn recovery_input_accepts_only_cipher_letters_and_actions() {
194 let request = HostRequest::RecoveryCharacter {
195 word_position: 4,
196 character_position: 2,
197 };
198 for (input, expected) in [
199 ("q", HostResponse::RecoveryCharacter('q')),
200 ("space", HostResponse::RecoveryNextWord),
201 ("backspace", HostResponse::RecoveryDelete),
202 ("done", HostResponse::RecoveryDone),
203 ] {
204 assert_eq!(
205 parse_host_response(&request, input.to_owned()),
206 Some(expected)
207 );
208 }
209 for input in ["Q", "qq", "", "delete", " space"] {
210 assert_eq!(parse_host_response(&request, input.to_owned()), None);
211 }
212 let prompt = host_prompt(&request);
213 assert!(prompt.contains("word 4"));
214 assert!(prompt.contains("character 2"));
215 }
216
217 #[test]
218 fn hidden_input_distinguishes_blank_lines_from_eof() {
219 assert_eq!(
220 completed_hidden_line(String::new(), true),
221 Some(String::new())
222 );
223 assert_eq!(completed_hidden_line(String::new(), false), None);
224 }
225
226 #[test]
227 fn host_input_eof_is_user_cancellation() {
228 let request = HostRequest::PinMatrix {
229 kind: PinMatrixRequestKind::Current,
230 };
231 assert!(matches!(
232 read_host_response(&request, || Ok(None), |_| Ok(())),
233 Err(e) if e.kind() == common::ErrorKind::UserCancelled
234 ));
235 }
236}