1use std::collections::BTreeMap;
2use std::io::{self, BufRead, Write};
3use std::process::Command;
4
5use crate::api::client::Client;
6use crate::cli::{AuthCmd, GitCredentialCmd};
7use crate::config::Config;
8use crate::error::{GiteeError, Result};
9
10pub fn execute(cmd: AuthCmd, host: &str) -> Result<()> {
11 match cmd {
12 AuthCmd::Login { token, force } => {
13 let token = match token {
14 Some(t) => t,
15 None => {
16 if !stdin_is_tty() {
20 return Err(GiteeError::Usage(
21 "auth login needs --token (or set GITEE_TOKEN) — stdin is not a terminal".into(),
22 ));
23 }
24 eprint!("Paste your Gitee personal access token: ");
25 io::stdout().flush().ok();
26 let mut line = String::new();
27 io::stdin().lock().read_line(&mut line).ok();
28 line.trim().to_string()
29 }
30 };
31 if token.is_empty() {
32 return Err(GiteeError::Usage(
33 "token required: pass --token or pipe via stdin".into(),
34 ));
35 }
36 if !force {
37 let client = Client::for_host(host, token.clone());
38 let user = client.users().me().map_err(|e| {
39 GiteeError::Usage(format!(
40 "token validation failed: {e}. Re-run with --force to store anyway."
41 ))
42 })?;
43 let who = user
44 .name
45 .filter(|n| !n.is_empty())
46 .unwrap_or_else(|| user.login.clone());
47 let login = if user.login.is_empty() {
48 who.clone()
49 } else {
50 user.login
51 };
52 Config::set_token_for_user(host, &login, &token)?;
53 println!("Logged in to {host} as {who}.");
54 } else {
55 Config::migrate_legacy_user(host)?;
56 if let Some(active) = Config::active_user(host)? {
57 Config::set_token_for_user(host, &active, &token)?;
58 println!("Logged in to {host} as {active} (--force; token not validated).");
59 } else {
60 Config::set_token_for_user(host, "oauth2", &token)?;
61 println!("Logged in to {host} as oauth2 (--force; token not validated).");
62 }
63 }
64 Ok(())
65 }
66 AuthCmd::Status => {
67 status(host)
68 }
69 AuthCmd::Token => {
70 let t = Config::token(host)?;
71 println!("{t}");
72 Ok(())
73 }
74 AuthCmd::Logout => {
75 Config::logout(host)?;
76 if std::env::var("GITEE_TOKEN")
77 .map(|v| !v.trim().is_empty())
78 .unwrap_or(false)
79 {
80 eprintln!(
81 "Warning: GITEE_TOKEN is set and will still override stored credentials."
82 );
83 }
84 println!("Logged out of {host}.");
85 Ok(())
86 }
87 AuthCmd::SetupGit => setup_git(host),
88 AuthCmd::Switch { user } => {
89 Config::switch_user(host, &user)?;
90 println!("Switched {host} account to {user}.");
91 Ok(())
92 }
93 AuthCmd::GitCredential(action) => git_credential(host, action),
94 }
95}
96
97fn stdin_is_tty() -> bool {
99 use std::io::IsTerminal;
100 std::io::stdin().is_terminal()
101}
102
103fn status(host: &str) -> Result<()> {
104 let active = Config::active_user(host)?;
105 let mut users = Config::known_users(host)?;
106 let src = Config::locate(host);
108
109 if users.is_empty() {
110 match src {
111 Some(src) if Config::has_legacy_token(host)? && active.is_none() => {
112 println!(
113 "Logged in to {host} via legacy host-only token (not migrated to a user account)."
114 );
115 println!("Active token via {}.", src.as_str());
116 }
117 Some(src) => println!("Logged in to {host} (via {}).", src.as_str()),
118 None => println!("Not logged in to {host}."),
119 }
120 return Ok(());
121 }
122
123 if src.is_none() {
125 println!(
126 "Not logged in to {host} (saved account metadata, but no token found)."
127 );
128 println!(
129 "Run `gitee auth login` to restore credentials, or `gitee auth logout` to clear metadata."
130 );
131 return Ok(());
132 }
133
134 users.sort();
135 println!("Logged in to {host}");
136 for u in users {
137 let mark = if Some(&u) == active.as_ref() {
138 "*"
139 } else {
140 " "
141 };
142 println!(" {mark} {u}");
143 }
144 if let Some(src) = src {
145 println!("Active token via {}.", src.as_str());
146 }
147 Ok(())
148}
149
150pub fn git_credential_helper_value(exe: &str) -> String {
152 format!("!\"{exe}\" auth git-credential")
153}
154
155fn setup_git(host: &str) -> Result<()> {
156 Config::migrate_legacy_user(host)?;
157 let exe = std::env::current_exe()
158 .map_err(|e| GiteeError::Usage(format!("current_exe: {e}")))?;
159 let exe = exe
160 .to_str()
161 .ok_or_else(|| GiteeError::Usage("current_exe path is not UTF-8".into()))?;
162 let key = format!("credential.https://{host}.helper");
163 let value = git_credential_helper_value(exe);
164 let status = Command::new("git")
165 .args(["config", "--global", &key, &value])
166 .status()
167 .map_err(|e| GiteeError::Usage(format!("git config: {e}")))?;
168 if !status.success() {
169 return Err(GiteeError::Usage(format!(
170 "git config failed setting {key}"
171 )));
172 }
173 println!("Configured git to use {exe} as a credential helper for https://{host}");
174 Ok(())
175}
176
177fn git_credential(host: &str, action: GitCredentialCmd) -> Result<()> {
178 let attrs = read_credential_attrs()?;
179 match action {
180 GitCredentialCmd::Get => credential_get(host, &attrs),
181 GitCredentialCmd::Store => credential_store(host, &attrs),
182 GitCredentialCmd::Erase => credential_erase(host, &attrs),
183 }
184}
185
186fn credential_get(default_host: &str, attrs: &BTreeMap<String, String>) -> Result<()> {
187 let protocol = attrs.get("protocol").map(String::as_str).unwrap_or("https");
188 if protocol != "https" && protocol != "http" {
189 return Ok(());
190 }
191 let req_host = attrs
192 .get("host")
193 .map(|s| s.split(':').next().unwrap_or(s))
194 .unwrap_or(default_host);
195 if req_host != default_host && !default_host.is_empty() {
197 if req_host != default_host {
200 }
202 }
203 let host = req_host;
204 let token = match Config::token(host) {
205 Ok(t) => t,
206 Err(_) => return Ok(()), };
208 let username = Config::credential_display_username(Config::active_user(host)?);
209 print_credential_attrs(&[
210 ("username", username.as_str()),
211 ("password", token.as_str()),
212 ])?;
213 Ok(())
214}
215
216fn credential_host_from_attrs(default_host: &str, attrs: &BTreeMap<String, String>) -> Option<String> {
217 let protocol = attrs.get("protocol").map(String::as_str).unwrap_or("https");
218 if protocol != "https" && protocol != "http" {
219 return None;
220 }
221 attrs
223 .get("host")
224 .map(|s| s.split(':').next().unwrap_or(s).to_string())
225 .or_else(|| {
226 if default_host.is_empty() {
227 None
228 } else {
229 Some(default_host.to_string())
230 }
231 })
232}
233
234fn credential_store(default_host: &str, attrs: &BTreeMap<String, String>) -> Result<()> {
235 let password = match attrs.get("password").map(String::as_str) {
236 Some(p) if !p.is_empty() => p,
237 _ => return Ok(()),
238 };
239 let Some(host) = credential_host_from_attrs(default_host, attrs) else {
240 return Err(GiteeError::Usage(
241 "credential store: could not determine host from request".into(),
242 ));
243 };
244 let username = attrs
245 .get("username")
246 .map(String::as_str)
247 .filter(|s| !s.is_empty() && *s != "default")
248 .unwrap_or("oauth2");
249 Config::set_token_for_user(&host, username, password)
250}
251
252fn credential_erase(default_host: &str, attrs: &BTreeMap<String, String>) -> Result<()> {
253 let _ = (default_host, attrs);
256 Ok(())
257}
258
259pub fn read_credential_attrs_from(reader: &mut dyn BufRead) -> Result<BTreeMap<String, String>> {
261 let mut map = BTreeMap::new();
262 loop {
263 let mut line = String::new();
264 let n = reader
265 .read_line(&mut line)
266 .map_err(|e| GiteeError::Usage(format!("read credential attrs: {e}")))?;
267 if n == 0 {
268 break;
269 }
270 let line = line.trim_end_matches(['\n', '\r']);
271 if line.is_empty() {
272 break;
273 }
274 if let Some((k, v)) = line.split_once('=') {
275 map.insert(k.to_string(), v.to_string());
276 }
277 }
278 Ok(map)
279}
280
281fn read_credential_attrs() -> Result<BTreeMap<String, String>> {
282 let mut stdin = io::stdin().lock();
283 read_credential_attrs_from(&mut stdin)
284}
285
286fn print_credential_attrs(pairs: &[(&str, &str)]) -> Result<()> {
287 let mut out = io::stdout().lock();
288 for (k, v) in pairs {
289 writeln!(out, "{k}={v}")?;
290 }
291 writeln!(out)?;
292 Ok(())
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use std::io::Cursor;
299
300 #[test]
301 fn parses_git_credential_protocol_lines() {
302 let mut cur = Cursor::new("protocol=https\nhost=gitee.com\npath=oschina/gitee-cli\n\n");
303 let map = read_credential_attrs_from(&mut cur).unwrap();
304 assert_eq!(map.get("protocol").map(String::as_str), Some("https"));
305 assert_eq!(map.get("host").map(String::as_str), Some("gitee.com"));
306 assert_eq!(
307 map.get("path").map(String::as_str),
308 Some("oschina/gitee-cli")
309 );
310 }
311
312 #[test]
316 fn auth_login_no_token_no_tty_errors_with_hint() {
317 let _env = crate::config::test_config_env_lock();
318 let err = execute(
319 AuthCmd::Login { token: None, force: false },
320 "gitee.test",
321 )
322 .expect_err("non-TTY login without token must error");
323 let msg = err.to_string();
324 assert!(
325 msg.contains("--token"),
326 "expected --token hint, got: {msg}"
327 );
328 }
329
330 #[test]
331 fn git_credential_helper_quotes_exe_path() {
332 assert_eq!(
333 git_credential_helper_value("/Applications/Gitee CLI.app/gitee"),
334 "!\"/Applications/Gitee CLI.app/gitee\" auth git-credential"
335 );
336 }
337
338 #[test]
339 fn credential_host_from_attrs_prefers_protocol_host() {
340 let mut attrs = BTreeMap::new();
341 attrs.insert("protocol".into(), "https".into());
342 attrs.insert("host".into(), "self.gitee.test".into());
343 assert_eq!(
344 credential_host_from_attrs("gitee.com", &attrs).as_deref(),
345 Some("self.gitee.test")
346 );
347 assert_eq!(
348 credential_host_from_attrs("gitee.com", &BTreeMap::new()).as_deref(),
349 Some("gitee.com")
350 );
351 }
352
353 #[test]
354 fn credential_store_uses_protocol_host_when_cli_default_differs() {
355 let _env = crate::config::test_config_env_lock();
356 let dir = std::env::temp_dir().join(format!(
357 "gitee-cli-store-host-test-{}",
358 std::process::id()
359 ));
360 let _ = std::fs::remove_dir_all(&dir);
361 std::fs::create_dir_all(&dir).unwrap();
362 crate::config::set_test_dir(Some(dir.clone()));
363 let mut attrs = BTreeMap::new();
364 attrs.insert("protocol".into(), "https".into());
365 attrs.insert("host".into(), "self.gitee.test".into());
366 attrs.insert("username".into(), "oauth2".into());
367 attrs.insert("password".into(), "pat-from-git".into());
368 credential_store("gitee.com", &attrs).unwrap();
369 assert_eq!(
370 Config::token_for_user("self.gitee.test", "oauth2").unwrap(),
371 "pat-from-git"
372 );
373 crate::config::set_test_dir(None);
374 let _ = std::fs::remove_dir_all(&dir);
375 }
376
377 #[test]
378 fn credential_store_persists_token_for_host() {
379 let _env = crate::config::test_config_env_lock();
380 let dir = std::env::temp_dir().join(format!(
381 "gitee-cli-store-test-{}",
382 std::process::id()
383 ));
384 let _ = std::fs::remove_dir_all(&dir);
385 std::fs::create_dir_all(&dir).unwrap();
386 crate::config::set_test_dir(Some(dir.clone()));
387 let mut attrs = BTreeMap::new();
388 attrs.insert("protocol".into(), "https".into());
389 attrs.insert("host".into(), "gitee.test".into());
390 attrs.insert("username".into(), "alice".into());
391 attrs.insert("password".into(), "pat-from-git".into());
392 credential_store("gitee.test", &attrs).unwrap();
393 assert_eq!(
394 Config::token_for_user("gitee.test", "alice").unwrap(),
395 "pat-from-git"
396 );
397 crate::config::set_test_dir(None);
398 let _ = std::fs::remove_dir_all(&dir);
399 }
400
401 #[test]
402 fn credential_erase_does_not_clear_cli_token() {
403 let _env = crate::config::test_config_env_lock();
404 let dir = std::env::temp_dir().join(format!(
405 "gitee-cli-erase-test-{}",
406 std::process::id()
407 ));
408 let _ = std::fs::remove_dir_all(&dir);
409 std::fs::create_dir_all(&dir).unwrap();
410 crate::config::set_test_dir(Some(dir.clone()));
411 Config::set_token_for_user("gitee.test", "alice", "secret").unwrap();
412 let mut attrs = BTreeMap::new();
413 attrs.insert("protocol".into(), "https".into());
414 attrs.insert("host".into(), "gitee.test".into());
415 attrs.insert("username".into(), "alice".into());
416 credential_erase("gitee.test", &attrs).unwrap();
417 assert_eq!(
418 Config::token_for_user("gitee.test", "alice").unwrap(),
419 "secret"
420 );
421 crate::config::set_test_dir(None);
422 let _ = std::fs::remove_dir_all(&dir);
423 }
424}