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}