use std::io::{IsTerminal, Read};
use zeroize::Zeroize;
use crate::util::{hex_to_bytes, CliError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretKind {
Seed,
RecipientKey,
}
impl SecretKind {
#[must_use]
pub fn env_var(self) -> &'static str {
match self {
SecretKind::Seed => "CARDANOWALL_SEED",
SecretKind::RecipientKey => "CARDANOWALL_RECIPIENT_KEY",
}
}
#[must_use]
pub fn flag(self) -> &'static str {
match self {
SecretKind::Seed => "seed",
SecretKind::RecipientKey => "secret-key",
}
}
fn prompt(self) -> &'static str {
match self {
SecretKind::Seed => "Enter 32-byte identity seed (hex): ",
SecretKind::RecipientKey => "Enter X25519 recipient secret key (hex): ",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SecretArgs {
pub value: Option<String>,
pub file: Option<String>,
pub stdin: bool,
}
impl SecretArgs {
#[must_use]
pub fn any_present(&self) -> bool {
self.file.is_some() || self.stdin || self.value.as_deref().is_some_and(|v| !v.is_empty())
}
}
pub trait SecretEnv {
fn var(&self, key: &str) -> Option<String>;
fn read_stdin(&self) -> Result<String, CliError>;
fn read_file(&self, path: &str) -> Result<String, CliError>;
fn stdin_is_terminal(&self) -> bool;
fn prompt_hidden(&self, prompt: &str) -> Result<String, CliError>;
}
pub struct SystemSecretEnv;
impl SecretEnv for SystemSecretEnv {
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.is_empty())
}
fn read_stdin(&self) -> Result<String, CliError> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::network(format!("cannot read stdin: {e}")))?;
Ok(buf)
}
fn read_file(&self, path: &str) -> Result<String, CliError> {
std::fs::read_to_string(path)
.map_err(|e| CliError::input(format!("cannot read secret file {path}: {e}")))
}
fn stdin_is_terminal(&self) -> bool {
std::io::stdin().is_terminal()
}
fn prompt_hidden(&self, prompt: &str) -> Result<String, CliError> {
rpassword::prompt_password(prompt)
.map_err(|e| CliError::input(format!("cannot read hidden prompt: {e}")))
}
}
fn trim_secret(raw: &str) -> String {
raw.trim().to_string()
}
pub fn resolve_secret_bytes(
kind: SecretKind,
args: &SecretArgs,
expected_len: usize,
required: bool,
cmd: &str,
env: &dyn SecretEnv,
) -> Result<Option<Vec<u8>>, CliError> {
let mut hex = match resolve_secret_hex(kind, args, required, cmd, env)? {
Some(hex) => hex,
None => return Ok(None),
};
let result = decode_and_check(kind, &hex, expected_len, cmd);
hex.zeroize();
result.map(Some)
}
fn resolve_secret_hex(
kind: SecretKind,
args: &SecretArgs,
required: bool,
cmd: &str,
env: &dyn SecretEnv,
) -> Result<Option<String>, CliError> {
if let Some(path) = args.file.as_deref().filter(|p| !p.is_empty()) {
return Ok(Some(trim_secret(&env.read_file(path)?)));
}
let stdin_sentinel = args.value.as_deref() == Some("-");
if args.stdin || stdin_sentinel {
return Ok(Some(trim_secret(&env.read_stdin()?)));
}
if let Some(value) = args.value.as_deref().filter(|v| !v.is_empty()) {
return Ok(Some(value.trim().to_string()));
}
if let Some(value) = env.var(kind.env_var()) {
return Ok(Some(value.trim().to_string()));
}
if required && env.stdin_is_terminal() {
let entered = env.prompt_hidden(kind.prompt())?;
let trimmed = trim_secret(&entered);
if trimmed.is_empty() {
return Err(CliError::input(format!(
"{cmd}: no {} provided",
kind.flag()
)));
}
return Ok(Some(trimmed));
}
if required {
Err(CliError::input(format!(
"{cmd}: --{flag} is required — pass --{flag}-file <path>, --{flag}-stdin, \
set {env}, or run interactively for a hidden prompt",
flag = kind.flag(),
env = kind.env_var(),
)))
} else {
Ok(None)
}
}
fn decode_and_check(
kind: SecretKind,
hex: &str,
expected_len: usize,
cmd: &str,
) -> Result<Vec<u8>, CliError> {
let bytes =
hex_to_bytes(hex).map_err(|e| CliError::input(format!("{cmd}: --{} {e}", kind.flag())))?;
if bytes.len() != expected_len {
return Err(CliError::input(format!(
"{cmd}: --{} must decode to exactly {expected_len} bytes (got {})",
kind.flag(),
bytes.len()
)));
}
Ok(bytes)
}
#[must_use]
pub fn resolve_config_value(
flag: Option<&str>,
env: Option<&str>,
profile: Option<&str>,
) -> Option<String> {
for candidate in [flag, env, profile] {
if let Some(value) = candidate.map(str::trim).filter(|v| !v.is_empty()) {
return Some(value.to_string());
}
}
None
}
#[derive(Debug, Clone, Default)]
pub struct ServiceGateway {
pub base_url: String,
pub api_key: Option<String>,
}
pub fn resolve_service_gateway(
base_url_flag: Option<&str>,
api_key_flag: Option<&str>,
profile: Option<&crate::config::GatewayProfile>,
cmd: &str,
env: &dyn SecretEnv,
) -> Result<ServiceGateway, CliError> {
let profile_base = profile.map(|p| p.base_url.as_str());
let profile_key = profile.and_then(|p| p.api_key.as_deref());
let base_url = resolve_config_value(
base_url_flag,
env.var("CARDANOWALL_BASE_URL").as_deref(),
profile_base,
)
.ok_or_else(|| {
CliError::input(format!(
"{cmd}: a gateway base URL is required — pass --base-url, set CARDANOWALL_BASE_URL, \
or configure a gateway profile (cardanowall gateway add …)"
))
})?;
let api_key = resolve_config_value(
api_key_flag,
env.var("CARDANOWALL_API_KEY").as_deref(),
profile_key,
);
Ok(ServiceGateway { base_url, api_key })
}
#[cfg(test)]
pub mod test_support {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
pub struct FakeSecretEnv {
pub vars: HashMap<String, String>,
pub files: HashMap<String, String>,
pub stdin: Option<String>,
pub terminal: bool,
pub prompt_response: Option<String>,
pub prompted: RefCell<bool>,
}
impl Default for FakeSecretEnv {
fn default() -> Self {
Self {
vars: HashMap::new(),
files: HashMap::new(),
stdin: None,
terminal: false,
prompt_response: None,
prompted: RefCell::new(false),
}
}
}
impl SecretEnv for FakeSecretEnv {
fn var(&self, key: &str) -> Option<String> {
self.vars.get(key).cloned().filter(|v| !v.is_empty())
}
fn read_stdin(&self) -> Result<String, CliError> {
self.stdin
.clone()
.ok_or_else(|| CliError::network("no stdin in fake".to_string()))
}
fn read_file(&self, path: &str) -> Result<String, CliError> {
self.files
.get(path)
.cloned()
.ok_or_else(|| CliError::input(format!("no fake file {path}")))
}
fn stdin_is_terminal(&self) -> bool {
self.terminal
}
fn prompt_hidden(&self, _prompt: &str) -> Result<String, CliError> {
*self.prompted.borrow_mut() = true;
self.prompt_response
.clone()
.ok_or_else(|| CliError::input("no prompt response in fake".to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::FakeSecretEnv as FakeEnv;
use super::*;
use std::collections::HashMap;
fn seed_hex() -> String {
"ab".repeat(32)
}
#[test]
fn file_beats_stdin_env_value() {
let env = FakeEnv {
files: HashMap::from([("/s".to_string(), format!("{}\n", seed_hex()))]),
stdin: Some("cd".repeat(32)),
vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
..FakeEnv::default()
};
let args = SecretArgs {
value: Some("12".repeat(32)),
file: Some("/s".to_string()),
stdin: true,
};
let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
.unwrap()
.unwrap();
assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
}
#[test]
fn stdin_beats_env_and_trims_newline() {
let env = FakeEnv {
stdin: Some(format!("{}\n", seed_hex())),
vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
..FakeEnv::default()
};
let args = SecretArgs {
stdin: true,
..SecretArgs::default()
};
let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
.unwrap()
.unwrap();
assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
}
#[test]
fn dash_value_means_stdin() {
let env = FakeEnv {
stdin: Some(seed_hex()),
..FakeEnv::default()
};
let args = SecretArgs {
value: Some("-".to_string()),
..SecretArgs::default()
};
let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
.unwrap()
.unwrap();
assert_eq!(bytes.len(), 32);
}
#[test]
fn argv_value_beats_env() {
let env = FakeEnv {
vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
..FakeEnv::default()
};
let args = SecretArgs {
value: Some(seed_hex()),
..SecretArgs::default()
};
let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
.unwrap()
.unwrap();
assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
}
#[test]
fn env_used_when_no_flag() {
let env = FakeEnv {
vars: HashMap::from([("CARDANOWALL_SEED".to_string(), seed_hex())]),
..FakeEnv::default()
};
let bytes = resolve_secret_bytes(
SecretKind::Seed,
&SecretArgs::default(),
32,
true,
"identity",
&env,
)
.unwrap()
.unwrap();
assert_eq!(bytes.len(), 32);
}
#[test]
fn missing_required_non_tty_is_input_error_no_prompt() {
let env = FakeEnv::default(); let err = resolve_secret_bytes(
SecretKind::Seed,
&SecretArgs::default(),
32,
true,
"identity",
&env,
)
.unwrap_err();
assert_eq!(err.code, 4);
assert!(!*env.prompted.borrow(), "must not prompt on a non-TTY");
}
#[test]
fn missing_optional_is_none() {
let env = FakeEnv::default();
let out = resolve_secret_bytes(
SecretKind::Seed,
&SecretArgs::default(),
32,
false,
"submit",
&env,
)
.unwrap();
assert!(out.is_none());
}
#[test]
fn prompt_used_only_on_tty_when_required() {
let env = FakeEnv {
terminal: true,
prompt_response: Some(format!("{}\n", seed_hex())),
..FakeEnv::default()
};
let bytes = resolve_secret_bytes(
SecretKind::Seed,
&SecretArgs::default(),
32,
true,
"identity",
&env,
)
.unwrap()
.unwrap();
assert_eq!(bytes.len(), 32);
assert!(*env.prompted.borrow());
}
#[test]
fn rejects_wrong_length() {
let env = FakeEnv {
vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "abcd".to_string())]),
..FakeEnv::default()
};
let err = resolve_secret_bytes(
SecretKind::Seed,
&SecretArgs::default(),
32,
true,
"identity",
&env,
)
.unwrap_err();
assert_eq!(err.code, 4);
}
#[test]
fn config_value_precedence() {
assert_eq!(
resolve_config_value(Some("flag"), Some("env"), Some("prof")),
Some("flag".to_string())
);
assert_eq!(
resolve_config_value(None, Some("env"), Some("prof")),
Some("env".to_string())
);
assert_eq!(
resolve_config_value(None, None, Some("prof")),
Some("prof".to_string())
);
assert_eq!(resolve_config_value(None, None, None), None);
assert_eq!(
resolve_config_value(Some(" "), None, Some("prof")),
Some("prof".to_string())
);
}
}