Skip to main content

gitee_cli_rs/cmd/
auth.rs

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                    // Never hang in non-interactive mode: if stdin is not a TTY,
17                    // the prompt would read nothing (or block on a pipe). Demand
18                    // an explicit `--token` (or `GITEE_TOKEN`) instead.
19                    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
97/// True when stdin is a terminal (so an interactive prompt won't hang).
98fn 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    // Readable token is the source of truth — known_users alone is not "logged in".
107    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    // Gate the multi-account "Logged in" banner on a readable token.
124    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
150/// gh-style credential helper value; quotes the exe path for spaces.
151pub 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    // Only answer for the configured host (ignore unrelated credential asks).
196    if req_host != default_host && !default_host.is_empty() {
197        // Still allow when --host matches attr host via CLI host default.
198        // If user runs helper without --host, default_host is gitee.com from clap.
199        if req_host != default_host {
200            // Prefer the host from the credential request when answering.
201        }
202    }
203    let host = req_host;
204    let token = match Config::token(host) {
205        Ok(t) => t,
206        Err(_) => return Ok(()), // git treats empty helper output as "no credentials"
207    };
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    // Prefer the credential protocol's host; fall back to the CLI default when absent.
222    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    // git may ask helpers to erase on auth failure. That must not wipe the CLI PAT
254    // managed by `gitee auth login` — leave store/erase of secrets to auth commands.
255    let _ = (default_host, attrs);
256    Ok(())
257}
258
259/// Parse git credential protocol lines (`key=value`) until a blank line.
260pub 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    /// `auth login` without `--token` and no TTY on stdin must error with a
313    /// message naming `--token`, not hang waiting for input. Cargo's test
314    /// harness pipes stdin from /dev/null, so `stdin_is_tty()` is false here.
315    #[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}