1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
//! Provide credential function to used with [git2](https://crates.io/crates/git2)::[RemoteCallbacks.credentials](https://docs.rs/git2/0.8.0/git2/struct.RemoteCallbacks.html#method.credentials)
//!
//! Usage:
//! ```rust
//! use git2;
//! use git2_credentials::CredentialHandler;
//! use tempfile;
//!
//! let mut cb = git2::RemoteCallbacks::new();
//! let git_config = git2::Config::open_default().unwrap();
//! let mut ch = CredentialHandler::new(git_config);
//! cb.credentials(move |url, username, allowed| ch.try_next_credential(url, username, allowed));
//!
//! // let mut fo = git2::FetchOptions::new();
//! // fo.remote_callbacks(cb)
//! //     .download_tags(git2::AutotagOption::All)
//! //     .update_fetchhead(true);
//! // let dst = tempfile::tempdir().unwrap();
//! // std::fs::create_dir_all(&dst.as_ref()).unwrap();
//! // git2::build::RepoBuilder::new()
//! //     .branch("master")
//! //     .fetch_options(fo)
//! //     .clone("git@github.com:davidB/git2_credentials.git", dst.as_ref()).unwrap();
//! ```
mod ssh_config;

#[cfg(feature = "ui4dialoguer")]
pub mod ui4dialoguer;

use failure::Error;
use git2;

pub struct CredentialHandler {
    usernames: Vec<String>,
    ssh_attempts_count: usize,
    username_attempts_count: usize,
    cred_helper_bad: Option<bool>,
    cfg: git2::Config,
    ui: Box<dyn CredentialUI>,
}

// implemention based on code & comment from cargo
// https://github.com/rust-lang/cargo/blob/master/src/cargo/sources/git/utils.rs#L415-L628
// License APACHE
// but adapted to not use wrapper over function like withXxx(FnMut), a more OO approach
impl CredentialHandler
{
    #[cfg(feature = "ui4dialoguer")]
    pub fn new(cfg: git2::Config) -> Self {
        use ui4dialoguer::CredentialUI4Dialoguer;
        Self::new_with_ui(cfg, Box::new(CredentialUI4Dialoguer{}))
    }

    pub fn new_with_ui(cfg: git2::Config, ui: Box<dyn CredentialUI>) -> Self
    {
        let mut usernames = Vec::new();
        usernames.push("".to_string()); //default
        usernames.push("git".to_string());
        if let Ok(s) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
            usernames.push(s);
        }

        // let mut cred_helper = git2::CredentialHelper::new(url);
        // cred_helper.config(cfg);
        // if let Some(ref s) = cred_helper.username {
        //     usernames.push(s.clone());
        // }

        CredentialHandler {
            usernames,
            ssh_attempts_count: 2,
            username_attempts_count: 0,
            cred_helper_bad: None,
            cfg,
            ui,
        }
    }

    /// Prepare the authentication callbacks for cloning a git repository.
    ///
    /// The main purpose of this function is to construct the "authentication
    /// callback" which is used to clone a repository. This callback will attempt to
    /// find the right authentication on the system (maybe with user input) and will
    /// guide libgit2 in doing so.
    ///
    /// The callback is provided `allowed` types of credentials, and we try to do as
    /// much as possible based on that:
    ///
    /// - Prioritize SSH keys from the local ssh agent as they're likely the most
    ///   reliable. The username here is prioritized from the credential
    ///   callback, then from whatever is configured in git itself, and finally
    ///   we fall back to the generic user of `git`. If no ssh agent try to use
    ///   the default key ($HOME/.ssh/id_rsa, $HOME/.ssh/id_ed25519)
    ///
    /// - If a username/password is allowed, then we fallback to git2-rs's
    ///   implementation of the credential helper. This is what is configured
    ///   with `credential.helper` in git, and is the interface for the macOS
    ///   keychain, for example. Else ask (on ui) the for username and password.
    ///
    /// - After the above two have failed, we just kinda grapple attempting to
    ///   return *something*.
    ///
    /// If any form of authentication fails, libgit2 will repeatedly ask us for
    /// credentials until we give it a reason to not do so. To ensure we don't
    /// just sit here looping forever we keep track of authentications we've
    /// attempted and we don't try the same ones again.
    pub fn try_next_credential(
        &mut self,
        url: &str,
        username: Option<&str>,
        allowed: git2::CredentialType,
    ) -> Result<git2::Cred, git2::Error> {
        // dbg!(allowed);

        // libgit2's "USERNAME" authentication actually means that it's just
        // asking us for a username to keep going. This is currently only really
        // used for SSH authentication and isn't really an authentication type.
        // The logic currently looks like:
        //
        //      let user = ...;
        //      if (user.is_null())
        //          user = callback(USERNAME, null, ...);
        //
        //      callback(SSH_KEY, user, ...)
        //
        // So if we're being called here then we know that (a) we're using ssh
        // authentication and (b) no username was specified in the URL that
        // we're trying to clone. We need to guess an appropriate username here,
        // but that may involve a few attempts.
        // (FIXME) Unfortunately we can't switch
        // usernames during one authentication session with libgit2, so to
        // handle this we bail out of this authentication session after setting
        // the flag `ssh_username_requested`, and then we handle this below.
        if allowed.contains(git2::CredentialType::USERNAME) {
            // debug_assert!(username.is_none());
            let idx = self.username_attempts_count;
            self.username_attempts_count += 1;
            return match self.usernames.get(idx).map(|s| &s[..]) {
                Some("") if username.is_none() => {
                    Err(git2::Error::from_str("gonna try usernames later"))
                }
                Some("") => git2::Cred::username(&username.unwrap_or("")),
                Some(s) => git2::Cred::username(&s),
                _ => Err(git2::Error::from_str("no more username to try")),
            };
        }

        // An "SSH_KEY" authentication indicates that we need some sort of SSH
        // authentication. This can currently either come from the ssh-agent
        // process or from a raw in-memory SSH key. Cargo only supports using
        // ssh-agent currently.
        //
        // If we get called with this then the only way that should be possible
        // is if a username is specified in the URL itself (e.g., `username` is
        // Some), hence the unwrap() here. We try custom usernames down below.
        if allowed.contains(git2::CredentialType::SSH_KEY) {
            // If ssh-agent authentication fails, libgit2 will keep
            // calling this callback asking for other authentication
            // methods to try. Make sure we only try ssh-agent once.
            self.ssh_attempts_count = (self.ssh_attempts_count + 1) % 3;
            // dbg!(self.ssh_attempts_count);
            let u = username.unwrap_or("git");
            return match self.ssh_attempts_count {
                0 => git2::Cred::ssh_key_from_agent(&u),
                1 => self.cred_from_ssh_config(&u),
                _ => Err(git2::Error::from_str("try with an other username")),
            };
        }

        // Sometimes libgit2 will ask for a username/password in plaintext.
        //
        // If ssh-agent authentication fails, libgit2 will keep calling this
        // callback asking for other authentication methods to try. Check
        // cred_helper_bad to make sure we only try the git credentail helper
        // once, to avoid looping forever.
        if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT)
            && self.cred_helper_bad.is_none()
        {
            let r = git2::Cred::credential_helper(&self.cfg, url, username);
            self.cred_helper_bad = Some(r.is_err());
            if r.is_err() {
                match self.ui.ask_user_password(username.unwrap_or("")) {
                    Ok((user, password)) => {
                        return git2::Cred::userpass_plaintext(&user, &password)
                    }
                    Err(_) => (), //FIXME give a feeback instead of ignore
                }
            }
            return r;
        }

        // I'm... not sure what the DEFAULT kind of authentication is, but seems
        // easy to support?
        if allowed.contains(git2::CredentialType::DEFAULT) {
            return git2::Cred::default();
        }

        // Stop trying
        Err(git2::Error::from_str("no valid authentication available"))
    }

    fn cred_from_ssh_config(&self, username: &str) -> Result<git2::Cred, git2::Error> {
        let (key, passphrase) = ssh_config::get_ssh_key_and_passphrase(self.ui.as_ref());
        return match key {
            Some(k) => {
                git2::Cred::ssh_key(username, None, &k, passphrase.as_ref().map(String::as_str))
            }
            None => Err(git2::Error::from_str(&format!(
                "failed authentication for repository"
            ))),
        };
    }
}

pub trait CredentialUI {
    fn ask_user_password(&self, username: &str) -> Result<(String, String), Error>;
    fn ask_ssh_passphrase(&self, passphrase_prompt: &str) -> Result<String, Error>;
}