1use std::io::{IsTerminal, Read};
30
31use zeroize::Zeroize;
32
33use crate::util::{hex_to_bytes, CliError};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SecretKind {
39 Seed,
41 RecipientKey,
43}
44
45impl SecretKind {
46 #[must_use]
48 pub fn env_var(self) -> &'static str {
49 match self {
50 SecretKind::Seed => "CARDANOWALL_SEED",
51 SecretKind::RecipientKey => "CARDANOWALL_RECIPIENT_KEY",
52 }
53 }
54
55 #[must_use]
57 pub fn flag(self) -> &'static str {
58 match self {
59 SecretKind::Seed => "seed",
60 SecretKind::RecipientKey => "secret-key",
61 }
62 }
63
64 fn prompt(self) -> &'static str {
66 match self {
67 SecretKind::Seed => "Enter 32-byte identity seed (hex): ",
68 SecretKind::RecipientKey => "Enter X25519 recipient secret key (hex): ",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default)]
78pub struct SecretArgs {
79 pub value: Option<String>,
81 pub file: Option<String>,
83 pub stdin: bool,
85}
86
87impl SecretArgs {
88 #[must_use]
90 pub fn any_present(&self) -> bool {
91 self.file.is_some() || self.stdin || self.value.as_deref().is_some_and(|v| !v.is_empty())
92 }
93}
94
95pub trait SecretEnv {
100 fn var(&self, key: &str) -> Option<String>;
102 fn read_stdin(&self) -> Result<String, CliError>;
104 fn read_file(&self, path: &str) -> Result<String, CliError>;
106 fn stdin_is_terminal(&self) -> bool;
108 fn prompt_hidden(&self, prompt: &str) -> Result<String, CliError>;
110}
111
112pub struct SystemSecretEnv;
114
115impl SecretEnv for SystemSecretEnv {
116 fn var(&self, key: &str) -> Option<String> {
117 std::env::var(key).ok().filter(|v| !v.is_empty())
118 }
119
120 fn read_stdin(&self) -> Result<String, CliError> {
121 let mut buf = String::new();
122 std::io::stdin()
123 .read_to_string(&mut buf)
124 .map_err(|e| CliError::network(format!("cannot read stdin: {e}")))?;
125 Ok(buf)
126 }
127
128 fn read_file(&self, path: &str) -> Result<String, CliError> {
129 std::fs::read_to_string(path)
130 .map_err(|e| CliError::input(format!("cannot read secret file {path}: {e}")))
131 }
132
133 fn stdin_is_terminal(&self) -> bool {
134 std::io::stdin().is_terminal()
135 }
136
137 fn prompt_hidden(&self, prompt: &str) -> Result<String, CliError> {
138 rpassword::prompt_password(prompt)
141 .map_err(|e| CliError::input(format!("cannot read hidden prompt: {e}")))
142 }
143}
144
145fn trim_secret(raw: &str) -> String {
148 raw.trim().to_string()
149}
150
151pub fn resolve_secret_bytes(
163 kind: SecretKind,
164 args: &SecretArgs,
165 expected_len: usize,
166 required: bool,
167 cmd: &str,
168 env: &dyn SecretEnv,
169) -> Result<Option<Vec<u8>>, CliError> {
170 let mut hex = match resolve_secret_hex(kind, args, required, cmd, env)? {
171 Some(hex) => hex,
172 None => return Ok(None),
173 };
174 let result = decode_and_check(kind, &hex, expected_len, cmd);
175 hex.zeroize();
176 result.map(Some)
177}
178
179fn resolve_secret_hex(
182 kind: SecretKind,
183 args: &SecretArgs,
184 required: bool,
185 cmd: &str,
186 env: &dyn SecretEnv,
187) -> Result<Option<String>, CliError> {
188 if let Some(path) = args.file.as_deref().filter(|p| !p.is_empty()) {
190 return Ok(Some(trim_secret(&env.read_file(path)?)));
191 }
192 let stdin_sentinel = args.value.as_deref() == Some("-");
194 if args.stdin || stdin_sentinel {
195 return Ok(Some(trim_secret(&env.read_stdin()?)));
196 }
197 if let Some(value) = args.value.as_deref().filter(|v| !v.is_empty()) {
199 return Ok(Some(value.trim().to_string()));
200 }
201 if let Some(value) = env.var(kind.env_var()) {
203 return Ok(Some(value.trim().to_string()));
204 }
205 if required && env.stdin_is_terminal() {
207 let entered = env.prompt_hidden(kind.prompt())?;
208 let trimmed = trim_secret(&entered);
209 if trimmed.is_empty() {
210 return Err(CliError::input(format!(
211 "{cmd}: no {} provided",
212 kind.flag()
213 )));
214 }
215 return Ok(Some(trimmed));
216 }
217 if required {
219 Err(CliError::input(format!(
220 "{cmd}: --{flag} is required — pass --{flag}-file <path>, --{flag}-stdin, \
221 set {env}, or run interactively for a hidden prompt",
222 flag = kind.flag(),
223 env = kind.env_var(),
224 )))
225 } else {
226 Ok(None)
227 }
228}
229
230fn decode_and_check(
231 kind: SecretKind,
232 hex: &str,
233 expected_len: usize,
234 cmd: &str,
235) -> Result<Vec<u8>, CliError> {
236 let bytes =
237 hex_to_bytes(hex).map_err(|e| CliError::input(format!("{cmd}: --{} {e}", kind.flag())))?;
238 if bytes.len() != expected_len {
239 return Err(CliError::input(format!(
240 "{cmd}: --{} must decode to exactly {expected_len} bytes (got {})",
241 kind.flag(),
242 bytes.len()
243 )));
244 }
245 Ok(bytes)
246}
247
248#[must_use]
257pub fn resolve_config_value(
258 flag: Option<&str>,
259 env: Option<&str>,
260 profile: Option<&str>,
261) -> Option<String> {
262 for candidate in [flag, env, profile] {
263 if let Some(value) = candidate.map(str::trim).filter(|v| !v.is_empty()) {
264 return Some(value.to_string());
265 }
266 }
267 None
268}
269
270#[derive(Debug, Clone, Default)]
272pub struct ServiceGateway {
273 pub base_url: String,
275 pub api_key: Option<String>,
277}
278
279pub fn resolve_service_gateway(
291 base_url_flag: Option<&str>,
292 api_key_flag: Option<&str>,
293 profile: Option<&crate::config::GatewayProfile>,
294 cmd: &str,
295 env: &dyn SecretEnv,
296) -> Result<ServiceGateway, CliError> {
297 let profile_base = profile.map(|p| p.base_url.as_str());
298 let profile_key = profile.and_then(|p| p.api_key.as_deref());
299
300 let base_url = resolve_config_value(
301 base_url_flag,
302 env.var("CARDANOWALL_BASE_URL").as_deref(),
303 profile_base,
304 )
305 .ok_or_else(|| {
306 CliError::input(format!(
307 "{cmd}: a gateway base URL is required — pass --base-url, set CARDANOWALL_BASE_URL, \
308 or configure a gateway profile (cardanowall gateway add …)"
309 ))
310 })?;
311
312 let api_key = resolve_config_value(
313 api_key_flag,
314 env.var("CARDANOWALL_API_KEY").as_deref(),
315 profile_key,
316 );
317
318 Ok(ServiceGateway { base_url, api_key })
319}
320
321#[cfg(test)]
325pub mod test_support {
326 use super::*;
327 use std::cell::RefCell;
328 use std::collections::HashMap;
329
330 pub struct FakeSecretEnv {
333 pub vars: HashMap<String, String>,
335 pub files: HashMap<String, String>,
337 pub stdin: Option<String>,
339 pub terminal: bool,
341 pub prompt_response: Option<String>,
343 pub prompted: RefCell<bool>,
345 }
346
347 impl Default for FakeSecretEnv {
348 fn default() -> Self {
349 Self {
350 vars: HashMap::new(),
351 files: HashMap::new(),
352 stdin: None,
353 terminal: false,
354 prompt_response: None,
355 prompted: RefCell::new(false),
356 }
357 }
358 }
359
360 impl SecretEnv for FakeSecretEnv {
361 fn var(&self, key: &str) -> Option<String> {
362 self.vars.get(key).cloned().filter(|v| !v.is_empty())
363 }
364 fn read_stdin(&self) -> Result<String, CliError> {
365 self.stdin
366 .clone()
367 .ok_or_else(|| CliError::network("no stdin in fake".to_string()))
368 }
369 fn read_file(&self, path: &str) -> Result<String, CliError> {
370 self.files
371 .get(path)
372 .cloned()
373 .ok_or_else(|| CliError::input(format!("no fake file {path}")))
374 }
375 fn stdin_is_terminal(&self) -> bool {
376 self.terminal
377 }
378 fn prompt_hidden(&self, _prompt: &str) -> Result<String, CliError> {
379 *self.prompted.borrow_mut() = true;
380 self.prompt_response
381 .clone()
382 .ok_or_else(|| CliError::input("no prompt response in fake".to_string()))
383 }
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::test_support::FakeSecretEnv as FakeEnv;
390 use super::*;
391 use std::collections::HashMap;
392
393 fn seed_hex() -> String {
394 "ab".repeat(32)
395 }
396
397 #[test]
398 fn file_beats_stdin_env_value() {
399 let env = FakeEnv {
400 files: HashMap::from([("/s".to_string(), format!("{}\n", seed_hex()))]),
401 stdin: Some("cd".repeat(32)),
402 vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
403 ..FakeEnv::default()
404 };
405 let args = SecretArgs {
406 value: Some("12".repeat(32)),
407 file: Some("/s".to_string()),
408 stdin: true,
409 };
410 let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
411 .unwrap()
412 .unwrap();
413 assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
414 }
415
416 #[test]
417 fn stdin_beats_env_and_trims_newline() {
418 let env = FakeEnv {
419 stdin: Some(format!("{}\n", seed_hex())),
420 vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
421 ..FakeEnv::default()
422 };
423 let args = SecretArgs {
424 stdin: true,
425 ..SecretArgs::default()
426 };
427 let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
428 .unwrap()
429 .unwrap();
430 assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
431 }
432
433 #[test]
434 fn dash_value_means_stdin() {
435 let env = FakeEnv {
436 stdin: Some(seed_hex()),
437 ..FakeEnv::default()
438 };
439 let args = SecretArgs {
440 value: Some("-".to_string()),
441 ..SecretArgs::default()
442 };
443 let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
444 .unwrap()
445 .unwrap();
446 assert_eq!(bytes.len(), 32);
447 }
448
449 #[test]
450 fn argv_value_beats_env() {
451 let env = FakeEnv {
452 vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
453 ..FakeEnv::default()
454 };
455 let args = SecretArgs {
456 value: Some(seed_hex()),
457 ..SecretArgs::default()
458 };
459 let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
460 .unwrap()
461 .unwrap();
462 assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
463 }
464
465 #[test]
466 fn env_used_when_no_flag() {
467 let env = FakeEnv {
468 vars: HashMap::from([("CARDANOWALL_SEED".to_string(), seed_hex())]),
469 ..FakeEnv::default()
470 };
471 let bytes = resolve_secret_bytes(
472 SecretKind::Seed,
473 &SecretArgs::default(),
474 32,
475 true,
476 "identity",
477 &env,
478 )
479 .unwrap()
480 .unwrap();
481 assert_eq!(bytes.len(), 32);
482 }
483
484 #[test]
485 fn missing_required_non_tty_is_input_error_no_prompt() {
486 let env = FakeEnv::default(); let err = resolve_secret_bytes(
488 SecretKind::Seed,
489 &SecretArgs::default(),
490 32,
491 true,
492 "identity",
493 &env,
494 )
495 .unwrap_err();
496 assert_eq!(err.code, 4);
497 assert!(!*env.prompted.borrow(), "must not prompt on a non-TTY");
498 }
499
500 #[test]
501 fn missing_optional_is_none() {
502 let env = FakeEnv::default();
503 let out = resolve_secret_bytes(
504 SecretKind::Seed,
505 &SecretArgs::default(),
506 32,
507 false,
508 "submit",
509 &env,
510 )
511 .unwrap();
512 assert!(out.is_none());
513 }
514
515 #[test]
516 fn prompt_used_only_on_tty_when_required() {
517 let env = FakeEnv {
518 terminal: true,
519 prompt_response: Some(format!("{}\n", seed_hex())),
520 ..FakeEnv::default()
521 };
522 let bytes = resolve_secret_bytes(
523 SecretKind::Seed,
524 &SecretArgs::default(),
525 32,
526 true,
527 "identity",
528 &env,
529 )
530 .unwrap()
531 .unwrap();
532 assert_eq!(bytes.len(), 32);
533 assert!(*env.prompted.borrow());
534 }
535
536 #[test]
537 fn rejects_wrong_length() {
538 let env = FakeEnv {
539 vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "abcd".to_string())]),
540 ..FakeEnv::default()
541 };
542 let err = resolve_secret_bytes(
543 SecretKind::Seed,
544 &SecretArgs::default(),
545 32,
546 true,
547 "identity",
548 &env,
549 )
550 .unwrap_err();
551 assert_eq!(err.code, 4);
552 }
553
554 #[test]
555 fn config_value_precedence() {
556 assert_eq!(
557 resolve_config_value(Some("flag"), Some("env"), Some("prof")),
558 Some("flag".to_string())
559 );
560 assert_eq!(
561 resolve_config_value(None, Some("env"), Some("prof")),
562 Some("env".to_string())
563 );
564 assert_eq!(
565 resolve_config_value(None, None, Some("prof")),
566 Some("prof".to_string())
567 );
568 assert_eq!(resolve_config_value(None, None, None), None);
569 assert_eq!(
571 resolve_config_value(Some(" "), None, Some("prof")),
572 Some("prof".to_string())
573 );
574 }
575}