Skip to main content

radicle_cli/terminal/
io.rs

1use anyhow::anyhow;
2use radicle::cob::Reaction;
3use radicle::cob::issue::Issue;
4use radicle::cob::thread::{Comment, CommentId};
5use radicle::crypto::SigningKey;
6use radicle::crypto::ssh::Keystore;
7use radicle::profile::env::RAD_PASSPHRASE;
8use radicle::profile::{Profile, Signer, SignerError};
9
10pub use radicle_term::io::*;
11pub use radicle_term::spinner;
12
13use inquire::validator;
14
15/// Validates secret key passphrases.
16#[derive(Clone)]
17pub struct PassphraseValidator {
18    keystore: Keystore,
19}
20
21impl PassphraseValidator {
22    /// Create a new validator.
23    #[must_use]
24    pub fn new(keystore: Keystore) -> Self {
25        Self { keystore }
26    }
27}
28
29impl inquire::validator::StringValidator for PassphraseValidator {
30    fn validate(
31        &self,
32        input: &str,
33    ) -> Result<validator::Validation, inquire::error::CustomUserError> {
34        let passphrase = Passphrase::from(input.to_owned());
35        if self.keystore.is_valid_passphrase(&passphrase)? {
36            Ok(validator::Validation::Valid)
37        } else {
38            Ok(validator::Validation::Invalid(
39                validator::ErrorMessage::from("Invalid passphrase, please try again"),
40            ))
41        }
42    }
43}
44
45/// Get the signer. First we try getting it from ssh-agent; otherwise, we prompt the user,
46/// if we're connected to a TTY.
47pub fn signer(profile: &Profile) -> anyhow::Result<Signer> {
48    let err = match profile.signer() {
49        Ok(signer) => return Ok(signer),
50        Err(err) => err,
51    };
52
53    match err {
54        SignerError::LoadError(radicle::crypto::LoadError::InvalidPassphrase) => {
55            super::warning(format!(
56                "The passphrase for your Radicle key provided in the environment variable `{RAD_PASSPHRASE}` is invalid. Please try again."
57            ));
58        }
59        SignerError::AgentConnection(err) => {
60            super::warning(format!(
61                "Failed to connect to ssh-agent: {err}. Falling back to passphrase prompt."
62            ));
63        }
64        SignerError::Agent(radicle::crypto::ssh::agent::IntoSignerError::IdentityNotFound {
65            identity,
66        }) => {
67            super::warning(format!(
68                "The Radicle key for `{identity}` is not registered with ssh-agent. Please run `rad auth` to register it."
69            ));
70        }
71        err @ SignerError::LoadError(_)
72        | err @ SignerError::InvalidPublicKey(_)
73        | err @ SignerError::Agent(_)
74        | err @ SignerError::Keystore(_) => return Err(anyhow!(err)),
75    }
76
77    let validator = PassphraseValidator::new(profile.keystore.clone());
78    let passphrase = match passphrase(validator)? {
79        Some(p) => p,
80        None => {
81            anyhow::bail!(
82                "A passphrase is required to read your Radicle key. Unable to continue. Consider setting the environment variable `{RAD_PASSPHRASE}`.",
83            )
84        }
85    };
86    let spinner = spinner("Unsealing key…");
87    let signer = SigningKey::load(&profile.keystore, Some(passphrase))?;
88
89    spinner.finish();
90
91    Ok(Signer::Key(signer))
92}
93
94pub fn comment_select(issue: &Issue) -> anyhow::Result<(&CommentId, &Comment)> {
95    let comments = issue.comments().collect::<Vec<_>>();
96    let selection = Select::new(
97        "Which comment do you want to react to?",
98        (0..comments.len()).collect(),
99    )
100    .with_render_config(*CONFIG)
101    .with_formatter(&|i| comments.get(i.index).unwrap().1.body().to_owned())
102    .prompt()?;
103
104    comments
105        .get(selection)
106        .copied()
107        .ok_or(anyhow!("failed to perform comment selection"))
108}
109
110pub fn reaction_select() -> anyhow::Result<Reaction> {
111    let emoji = Select::new(
112        "With which emoji do you want to react?",
113        vec!['🐙', '👾', '💯', '✨', '🙇', '🙅', '❤'],
114    )
115    .with_render_config(*CONFIG)
116    .prompt()?;
117    Ok(Reaction::new(emoji)?)
118}