gw_bin/checks/git/
credentials.rs

1// Credential funtion graciously lifted from https://github.com/davidB/git2_credentials
2// The goal is to remove every extra feature (e.g. interactive usage, config parsing with pest)
3
4use dirs::home_dir;
5use log::{trace, warn};
6use std::path::PathBuf;
7
8pub use git2;
9
10#[derive(Debug, Clone)]
11pub enum CredentialAuth {
12    Ssh(String),
13    Https(String, String),
14}
15
16pub struct CredentialHandler {
17    username_attempts_count: usize,
18    username_candidates: Vec<String>,
19    ssh_attempts_count: usize,
20    ssh_key_candidates: Vec<std::path::PathBuf>,
21    https_credentials: Option<(String, String)>,
22    cred_helper_bad: Option<bool>,
23    cfg: git2::Config,
24}
25
26// implemention based on code & comment from cargo
27// https://github.com/rust-lang/cargo/blob/master/src/cargo/sources/git/utils.rs#L415-L628
28// License APACHE
29// but adapted to not use wrapper over function like withXxx(FnMut), a more OO approach
30impl CredentialHandler {
31    pub fn new(cfg: git2::Config, auth: Option<CredentialAuth>) -> Self {
32        // Force using https credentials if given
33        if let Some(CredentialAuth::Https(username, password)) = auth {
34            return CredentialHandler {
35                username_attempts_count: 0,
36                username_candidates: vec!["git".to_string()],
37                ssh_attempts_count: 0,
38                ssh_key_candidates: vec![],
39                cred_helper_bad: None,
40                https_credentials: Some((username, password)),
41                cfg,
42            };
43        }
44
45        // Generate a list of available keys
46        let ssh_keys = if let Some(CredentialAuth::Ssh(path)) = auth {
47            vec![PathBuf::from(path)]
48        } else {
49            let home = home_dir().unwrap_or(PathBuf::from("~"));
50            vec![
51                home.join(".ssh/id_dsa"),
52                home.join(".ssh/id_ecdsa"),
53                home.join(".ssh/id_ecdsa_sk"),
54                home.join(".ssh/id_ed25519"),
55                home.join(".ssh/id_ed25519_sk"),
56                home.join(".ssh/id_rsa"),
57            ]
58        };
59
60        let ssh_key_candidates: Vec<PathBuf> = ssh_keys
61            .into_iter()
62            .filter(|key_path| key_path.exists())
63            .collect();
64
65        CredentialHandler {
66            username_attempts_count: 0,
67            username_candidates: vec!["git".to_string()],
68            ssh_attempts_count: 0,
69            ssh_key_candidates,
70            cred_helper_bad: None,
71            https_credentials: None,
72            cfg,
73        }
74    }
75
76    /// Prepare the authentication callbacks for cloning a git repository.
77    ///
78    /// The main purpose of this function is to construct the "authentication
79    /// callback" which is used to clone a repository. This callback will attempt to
80    /// find the right authentication on the system (maybe with user input) and will
81    /// guide libgit2 in doing so.
82    ///
83    /// The callback is provided `allowed` types of credentials, and we try to do as
84    /// much as possible based on that:
85    ///
86    /// - Prioritize SSH keys from the local ssh agent as they're likely the most
87    ///   reliable. The username here is prioritized from the credential
88    ///   callback, then from whatever is configured in git itself, and finally
89    ///   we fall back to the generic user of `git`. If no ssh agent try to use
90    ///   the default key ($HOME/.ssh/id_rsa, $HOME/.ssh/id_ed25519)
91    ///
92    /// - If a username/password is allowed, then we fallback to git2-rs's
93    ///   implementation of the credential helper. This is what is configured
94    ///   with `credential.helper` in git, and is the interface for the macOS
95    ///   keychain, for example. Else ask (on ui) the for username and password.
96    ///
97    /// - After the above two have failed, we just kinda grapple attempting to
98    ///   return *something*.
99    ///
100    /// If any form of authentication fails, libgit2 will repeatedly ask us for
101    /// credentials until we give it a reason to not do so. To ensure we don't
102    /// just sit here looping forever we keep track of authentications we've
103    /// attempted and we don't try the same ones again.
104    pub fn try_next_credential(
105        &mut self,
106        url: &str,
107        username: Option<&str>,
108        allowed: git2::CredentialType,
109    ) -> Result<git2::Cred, git2::Error> {
110        // libgit2's "USERNAME" authentication actually means that it's just
111        // asking us for a username to keep going. This is currently only really
112        // used for SSH authentication and isn't really an authentication type.
113        // The logic currently looks like:
114        //
115        //      let user = ...;
116        //      if (user.is_null())
117        //          user = callback(USERNAME, null, ...);
118        //
119        //      callback(SSH_KEY, user, ...)
120        //
121        // So if we're being called here then we know that (a) we're using ssh
122        // authentication and (b) no username was specified in the URL that
123        // we're trying to clone. We need to guess an appropriate username here,
124        // but that may involve a few attempts.
125        // (FIXME) Unfortunately we can't switch
126        // usernames during one authentication session with libgit2, so to
127        // handle this we bail out of this authentication session after setting
128        // the flag `ssh_username_requested`, and then we handle this below.
129        if allowed.contains(git2::CredentialType::USERNAME) {
130            // debug_assert!(username.is_none());
131            let idx = self.username_attempts_count;
132            self.username_attempts_count += 1;
133            return match self.username_candidates.get(idx).map(|s| &s[..]) {
134                Some(s) => git2::Cred::username(s),
135                _ => Err(git2::Error::from_str("no more username to try")),
136            };
137        }
138
139        // An "SSH_KEY" authentication indicates that we need some sort of SSH
140        // authentication. This can currently either come from the ssh-agent
141        // process or from a raw in-memory SSH key.
142        //
143        // If we get called with this then the only way that should be possible
144        // is if a username is specified in the URL itself (e.g., `username` is
145        // Some), hence the unwrap() here. We try custom usernames down below.
146        if allowed.contains(git2::CredentialType::SSH_KEY) {
147            // If ssh-agent authentication fails, libgit2 will keep
148            // calling this callback asking for other authentication
149            // methods to try. Make sure we only try ssh-agent once.
150            self.ssh_attempts_count += 1;
151            let u = username.unwrap_or("git");
152            return if self.ssh_attempts_count == 1 {
153                trace!("Trying ssh-key from agent with username {u}.");
154                git2::Cred::ssh_key_from_agent(u)
155            } else {
156                let candidate_idx = self.ssh_attempts_count - 2;
157                if candidate_idx < self.ssh_key_candidates.len() {
158                    let key = self.ssh_key_candidates.get(candidate_idx);
159                    match key {
160                        // try without passphrase
161                        Some(k) => {
162                            trace!("Trying ssh-key {} without passphrase.", k.to_string_lossy());
163                            git2::Cred::ssh_key(u, None, k, None)
164                        }
165                        None => Err(git2::Error::from_str(
166                            "failed ssh authentication for repository",
167                        )),
168                    }
169                } else {
170                    if self.ssh_key_candidates.is_empty() {
171                        warn!("There are no ssh-keys in ~/.ssh, run ssh-keygen or mount your .ssh directory.");
172                    }
173                    Err(git2::Error::from_str(
174                        "no ssh-key found that can authenticate to your repository",
175                    ))
176                }
177            };
178        }
179
180        // Sometimes libgit2 will ask for a username/password in plaintext.
181        //
182        // If ssh-agent authentication fails, libgit2 will keep calling this
183        // callback asking for other authentication methods to try. Check
184        // cred_helper_bad to make sure we only try the git credentail helper
185        // once, to avoid looping forever.
186        if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT)
187            && self.cred_helper_bad.is_none()
188        {
189            if let Some((username, password)) = &self.https_credentials {
190                trace!("Trying username-password from command line argument {username}.");
191                return git2::Cred::userpass_plaintext(username, password);
192            }
193
194            trace!("Trying username-password authentication from credential helper.");
195            let r = git2::Cred::credential_helper(&self.cfg, url, username);
196            self.cred_helper_bad = Some(r.is_err());
197            return r;
198        }
199
200        // I'm... not sure what the DEFAULT kind of authentication is, but seems
201        // easy to support?
202        if allowed.contains(git2::CredentialType::DEFAULT) {
203            return git2::Cred::default();
204        }
205
206        // Stop trying
207        trace!("There are not authentication available.");
208        Err(git2::Error::from_str("no valid authentication available"))
209    }
210}