at main 11 kB view raw
1use base58::ToBase58; 2use base64::Engine; 3use cid::multihash; 4use clap::{CommandFactory, Parser, Subcommand}; 5use rustyline::completion::{Completer, Pair}; 6use rustyline::error::ReadlineError; 7use rustyline::highlight::{CmdKind, Highlighter}; 8use rustyline::hint::Hinter; 9use rustyline::validate::{ValidationContext, ValidationResult, Validator}; 10use rustyline::{Context, Editor, Helper}; 11use sha2::Digest; 12 13use hidapi::HidApi; 14use ledger_transport_hid::TransportNativeHID; 15 16use client::{ 17 transport::{Transport, TransportHID, TransportTcp, TransportWrapper}, 18 vanadium_client::{NativeAppClient, VanadiumAppClient}, 19 AtprotoAppClient, 20}; 21 22use std::borrow::Cow; 23use std::fs::File; 24use std::io::Read; 25use std::sync::Arc; 26 27#[derive(Parser, Debug)] 28#[command(name = "vnd-bitcoin-cli")] 29struct Cli { 30 #[clap(subcommand)] 31 command: CliCommand, 32} 33 34#[derive(Subcommand, Debug)] 35#[clap(rename_all = "snake_case")] 36enum CliCommand { 37 GetDidKey { 38 #[clap(long)] 39 key_index: Option<u32>, 40 #[clap(long, default_missing_value = "true", num_args = 0..=1)] 41 display: bool, 42 }, 43 SignPlcOperation { 44 #[clap(long)] 45 key_index: Option<u32>, 46 /// Path to operation json file 47 #[clap(long)] 48 operation: String, 49 /// Path to previous operation json file 50 #[clap(long)] 51 previous: Option<String>, 52 #[clap(long, default_missing_value = "true", num_args = 0..=1)] 53 new_plc_did: bool, 54 }, 55 GetCID { 56 /// Path to operation json file 57 #[clap(long)] 58 operation: String, 59 }, 60 Exit, 61} 62 63// Command completer 64struct CommandCompleter; 65 66impl CommandCompleter { 67 fn get_current_word<'a>(&self, line: &'a str, pos: usize) -> (usize, &'a str) { 68 let before = &line[..pos]; 69 // Find the last space before the cursor; if none, start at 0 70 let start = before.rfind(' ').map_or(0, |i| i + 1); 71 let word = &line[start..pos]; 72 (start, word) 73 } 74} 75 76fn make_pair(s: &str) -> Pair { 77 Pair { 78 display: s.to_string(), 79 replacement: s.to_string(), 80 } 81} 82 83impl Completer for CommandCompleter { 84 type Candidate = Pair; 85 86 fn complete( 87 &self, 88 line: &str, 89 pos: usize, 90 _ctx: &Context<'_>, 91 ) -> rustyline::Result<(usize, Vec<Pair>)> { 92 let prefix = line[..pos].trim_start(); 93 94 // Case 1: Empty input, suggest all subcommands 95 if prefix.is_empty() || !prefix.contains(' ') { 96 let suggestions = Cli::command() 97 .get_subcommands() 98 .filter(|cmd| cmd.get_name().starts_with(prefix)) 99 .map(|cmd| make_pair(cmd.get_name())) 100 .collect(); 101 return Ok((0, suggestions)); 102 } 103 104 // Case 3: Subcommand present; suggest possible arguments to complete the command 105 let subcmd_name = prefix.split_whitespace().next().unwrap(); 106 if let Some(subcmd) = Cli::command().find_subcommand(subcmd_name) { 107 let (start, _) = self.get_current_word(line, pos); 108 109 // Collect arguments already present in the line before the cursor 110 let Ok(present_args) = shellwords::split(&line[..start].trim_end()) else { 111 return Ok((0, vec![])); // no suggestions if we can't parse the line 112 }; 113 114 // replace `argument=some_value` with just `argument` for each of present_args 115 let present_args: Vec<String> = present_args 116 .into_iter() 117 .map(|arg| arg.split('=').next().unwrap().to_string()) 118 .collect(); 119 120 // Get all argument continuations 121 let suggestions = subcmd 122 .get_arguments() 123 .filter_map(|arg| arg.get_long().map(|l| l.to_string())) 124 .filter(|arg| !present_args.contains(arg)) 125 .map(|arg| Pair { 126 display: arg.clone(), 127 replacement: arg, 128 }) 129 .collect(); 130 return Ok((start, suggestions)); 131 } 132 133 // Default case: no suggestions 134 Ok((0, vec![])) 135 } 136} 137 138impl Validator for CommandCompleter { 139 fn validate( 140 &self, 141 _ctx: &mut ValidationContext<'_>, 142 ) -> Result<ValidationResult, ReadlineError> { 143 Ok(ValidationResult::Valid(None)) 144 } 145} 146 147impl Highlighter for CommandCompleter { 148 fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { 149 Cow::Borrowed(line) 150 } 151 152 fn highlight_char(&self, _line: &str, _pos: usize, _cmd_kind: CmdKind) -> bool { 153 false 154 } 155} 156 157impl Hinter for CommandCompleter { 158 type Hint = String; 159 160 fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> { 161 None 162 } 163} 164 165impl Helper for CommandCompleter {} 166 167#[derive(Parser)] 168#[command(name = "Vanadium", about = "Run a V-App on Vanadium")] 169struct Args { 170 /// Path to the ELF file of the V-App (if not the default one) 171 app: Option<String>, 172 173 /// Use the HID interface for a real device, instead of Speculos 174 #[arg(long, group = "interface")] 175 hid: bool, 176 177 /// Use the native interface 178 #[arg(long, group = "interface")] 179 native: bool, 180} 181 182// a bit of a hack: we convert the prompt in a format that clap can parse 183// (adding a dummy command, and replacing each 'argument' with '--argument') 184fn prepare_prompt_for_clap(line: &str) -> Result<Vec<String>, String> { 185 let args = shellwords::split(line).map_err(|e| format!("Failed to parse input: {}", e))?; 186 if args.is_empty() { 187 return Err("Empty input".to_string()); 188 } 189 190 // dummy command, and first command unchanged 191 let mut clap_args = vec!["dummy".to_string(), args[0].clone()]; 192 193 // prepend `--` to each subsequent argument 194 for arg in &args[1..] { 195 clap_args.push(format!("--{}", arg)); 196 } 197 Ok(clap_args) 198} 199 200async fn handle_cli_command( 201 app_client: &mut AtprotoAppClient, 202 cli: &Cli, 203) -> Result<(), Box<dyn std::error::Error>> { 204 match &cli.command { 205 CliCommand::GetDidKey { key_index, display } => { 206 let key = app_client 207 .get_did_key(key_index.unwrap_or(0), *display) 208 .await?; 209 println!("did:key:z{}", key.to_base58()); 210 } 211 CliCommand::SignPlcOperation { 212 key_index, 213 operation, 214 previous, 215 new_plc_did, 216 } => { 217 let operation: client::PlcOperation = read_json_file(&operation)?; 218 let previous: Option<client::SignedPlcOperation> = if let Some(path) = previous { 219 Some(read_json_file(&path)?) 220 } else { 221 None 222 }; 223 let sig = app_client 224 .sign_plc_operation(key_index.unwrap_or(0), operation.clone(), previous) 225 .await 226 .map(|s| base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(s))?; 227 println!("sig: {}", sig); 228 if *new_plc_did { 229 let b = serde_ipld_dagcbor::to_vec(&operation.signed(sig)).unwrap(); 230 let hash = sha2::Sha256::digest(b); 231 let s = base32::encode(base32::Alphabet::Rfc4648Lower { padding: true }, &hash); 232 eprintln!("did:plc:{}", &s[..24]); 233 } 234 } 235 CliCommand::GetCID { operation } => { 236 let operation: client::SignedPlcOperation = read_json_file(&operation)?; 237 let b = serde_ipld_dagcbor::to_vec(&operation).unwrap(); 238 let digest = sha2::Sha256::digest(b); 239 let wrap = multihash::Multihash::wrap(0x12, &digest).unwrap(); 240 let cid = cid::Cid::new(cid::Version::V1, 0x71, wrap).unwrap(); 241 eprintln!("cid: {}", cid); 242 } 243 CliCommand::Exit => { 244 app_client.exit().await?; 245 return Err("Exiting".into()); 246 } 247 } 248 Ok(()) 249} 250 251fn read_json_file<T>(path: &str) -> Result<T, Box<dyn std::error::Error>> 252where 253 T: serde::de::DeserializeOwned, 254{ 255 let mut file = File::open(path)?; 256 let mut contents = String::new(); 257 file.read_to_string(&mut contents)?; 258 let data: T = serde_json::from_str(&contents)?; 259 Ok(data) 260} 261 262#[tokio::main(flavor = "multi_thread")] 263async fn main() -> Result<(), Box<dyn std::error::Error>> { 264 let args = Args::parse(); 265 266 let default_app_path = if args.native { 267 "../target/x86_64-unknown-linux-gnu/release/vnd-atproto" 268 } else { 269 "../target/riscv32imc-unknown-none-elf/release/vnd-atproto" 270 }; 271 272 let app_path_str = args.app.unwrap_or(default_app_path.to_string()); 273 274 let mut app_client = if args.native { 275 AtprotoAppClient::new(Box::new( 276 NativeAppClient::new(&app_path_str) 277 .await 278 .map_err(|_| "Failed to create client")?, 279 )) 280 } else { 281 let transport_raw: Arc< 282 dyn Transport<Error = Box<dyn std::error::Error + Send + Sync>> + Send + Sync, 283 > = if args.hid { 284 Arc::new(TransportHID::new( 285 TransportNativeHID::new( 286 &HidApi::new().expect("Unable to get connect to the device"), 287 ) 288 .unwrap(), 289 )) 290 } else { 291 Arc::new( 292 TransportTcp::new() 293 .await 294 .expect("Unable to get TCP transport. Is speculos running?"), 295 ) 296 }; 297 let transport = TransportWrapper::new(transport_raw.clone()); 298 299 let (client, _) = VanadiumAppClient::new(&app_path_str, Arc::new(transport), None) 300 .await 301 .map_err(|e| format!("Failed to create client: {}", e))?; 302 303 AtprotoAppClient::new(Box::new(client)) 304 }; 305 306 let mut rl = Editor::<CommandCompleter, rustyline::history::DefaultHistory>::new()?; 307 rl.set_helper(Some(CommandCompleter)); 308 309 let _ = rl.load_history("history.txt"); 310 311 loop { 312 match rl.readline("> ") { 313 Ok(line) => { 314 if line.trim().is_empty() { 315 continue; 316 } 317 rl.add_history_entry(line.as_str())?; 318 319 let clap_args = match prepare_prompt_for_clap(&line) { 320 Ok(args) => args, 321 Err(e) => { 322 println!("Error: {}", e); 323 continue; 324 } 325 }; 326 327 match Cli::try_parse_from(clap_args) { 328 Ok(cli) => { 329 if let Err(e) = handle_cli_command(&mut app_client, &cli).await { 330 println!("Error: {}", e); 331 } 332 } 333 Err(e) => println!("Invalid command: {}", e), 334 } 335 } 336 Err(ReadlineError::Interrupted) => println!("Interrupted"), 337 Err(ReadlineError::Eof) => { 338 println!("Exiting"); 339 break; 340 } 341 Err(err) => { 342 println!("Error: {:?}", err); 343 break; 344 } 345 } 346 } 347 348 rl.save_history("history.txt")?; 349 350 app_client.exit().await?; 351 352 Ok(()) 353}