1use std::io::IsTerminal;
2use std::path::Path;
3use std::process::Command;
4use std::{fs, io};
5
6use crate::auth::output;
7
8const GOOGLE_USERINFO_URL: &str = "https://openidconnect.googleapis.com/v1/userinfo";
9
10#[derive(Copy, Clone, Debug, Eq, PartialEq)]
11enum LoginMethod {
12 GeminiBrowser,
13 GeminiDeviceCode,
14 ApiKey,
15}
16
17pub fn run(api_key: bool, device_code: bool) -> i32 {
18 run_with_json(api_key, device_code, false)
19}
20
21pub fn run_with_json(api_key: bool, device_code: bool, output_json: bool) -> i32 {
22 let method = match resolve_method(api_key, device_code) {
23 Ok(method) => method,
24 Err((code, message, details)) => {
25 if output_json {
26 let _ = output::emit_error("auth login", "invalid-usage", message, details);
27 } else {
28 eprintln!("{message}");
29 }
30 return code;
31 }
32 };
33
34 if method == LoginMethod::ApiKey {
35 return run_api_key_login(output_json);
36 }
37
38 run_oauth_login(method, output_json)
39}
40
41fn run_api_key_login(output_json: bool) -> i32 {
42 let source = if std::env::var("GEMINI_API_KEY")
43 .ok()
44 .filter(|value| !value.trim().is_empty())
45 .is_some()
46 {
47 Some("GEMINI_API_KEY")
48 } else if std::env::var("GOOGLE_API_KEY")
49 .ok()
50 .filter(|value| !value.trim().is_empty())
51 .is_some()
52 {
53 Some("GOOGLE_API_KEY")
54 } else {
55 None
56 };
57
58 let Some(source) = source else {
59 if output_json {
60 let _ = output::emit_error(
61 "auth login",
62 "missing-api-key",
63 "gemini-login: set GEMINI_API_KEY or GOOGLE_API_KEY before using --api-key",
64 None,
65 );
66 } else {
67 eprintln!("gemini-login: set GEMINI_API_KEY or GOOGLE_API_KEY before using --api-key");
68 }
69 return 64;
70 };
71
72 if output_json {
73 let _ = output::emit_result(
74 "auth login",
75 output::obj(vec![
76 ("method", output::s("api-key")),
77 ("provider", output::s("gemini-api")),
78 ("completed", output::b(true)),
79 ("source", output::s(source)),
80 ]),
81 );
82 } else {
83 println!("gemini: login complete (method: api-key)");
84 }
85
86 0
87}
88
89fn run_oauth_login(method: LoginMethod, output_json: bool) -> i32 {
90 if output_json {
91 return run_oauth_session_check(method, true);
92 }
93
94 let interactive_terminal = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
95 if !interactive_terminal {
96 return run_oauth_session_check(method, false);
98 }
99
100 run_oauth_interactive_login(method)
101}
102
103fn run_oauth_session_check(method: LoginMethod, output_json: bool) -> i32 {
104 let auth_file = match crate::paths::resolve_auth_file() {
105 Some(path) => path,
106 None => {
107 emit_login_error(
108 output_json,
109 "auth-file-not-configured",
110 "gemini-login: GEMINI_AUTH_FILE is not configured".to_string(),
111 None,
112 );
113 return 1;
114 }
115 };
116
117 if !auth_file.is_file() {
118 emit_login_error(
119 output_json,
120 "auth-file-not-found",
121 format!("gemini-login: auth file not found: {}", auth_file.display()),
122 Some(output::obj(vec![(
123 "auth_file",
124 output::s(auth_file.display().to_string()),
125 )])),
126 );
127 return 1;
128 }
129
130 let mut refresh_attempted = false;
131 if has_refresh_token(&auth_file) {
132 refresh_attempted = true;
133 let _ = crate::auth::refresh::run_silent(&[]);
135 }
136
137 let auth_json = match crate::json::read_json(&auth_file) {
138 Ok(value) => value,
139 Err(err) => {
140 emit_login_error(
141 output_json,
142 "auth-read-failed",
143 format!(
144 "gemini-login: failed to read auth file {}",
145 auth_file.display()
146 ),
147 Some(output::obj(vec![
148 ("auth_file", output::s(auth_file.display().to_string())),
149 ("error", output::s(err.to_string())),
150 ])),
151 );
152 return 1;
153 }
154 };
155
156 let access_token = access_token_from_json(&auth_json);
157 let access_token = match access_token {
158 Some(token) => token,
159 None => {
160 emit_login_error(
161 output_json,
162 "missing-access-token",
163 format!(
164 "gemini-login: missing access token in {}",
165 auth_file.display()
166 ),
167 Some(output::obj(vec![(
168 "auth_file",
169 output::s(auth_file.display().to_string()),
170 )])),
171 );
172 return 2;
173 }
174 };
175
176 let userinfo = match fetch_google_userinfo(&access_token) {
177 Ok(value) => value,
178 Err(err) => {
179 emit_login_error(output_json, err.code, err.message, err.details);
180 return err.exit_code;
181 }
182 };
183
184 let email = userinfo
185 .get("email")
186 .and_then(|value| value.as_str())
187 .unwrap_or_default()
188 .to_string();
189
190 if output_json {
191 let _ = output::emit_result(
192 "auth login",
193 output::obj(vec![
194 ("method", output::s(method.as_str())),
195 ("provider", output::s(method.provider())),
196 ("completed", output::b(true)),
197 ("auth_file", output::s(auth_file.display().to_string())),
198 (
199 "email",
200 if email.is_empty() {
201 output::null()
202 } else {
203 output::s(email)
204 },
205 ),
206 ("refresh_attempted", output::b(refresh_attempted)),
207 ]),
208 );
209 } else {
210 println!("gemini: login complete (method: {})", method.as_str());
211 }
212
213 0
214}
215
216fn run_oauth_interactive_login(method: LoginMethod) -> i32 {
217 let auth_file = match crate::paths::resolve_auth_file() {
218 Some(path) => path,
219 None => {
220 emit_login_error(
221 false,
222 "auth-file-not-configured",
223 "gemini-login: GEMINI_AUTH_FILE is not configured".to_string(),
224 None,
225 );
226 return 1;
227 }
228 };
229
230 let backup = match backup_auth_file(&auth_file) {
231 Ok(backup) => backup,
232 Err(err) => {
233 emit_login_error(false, "auth-read-failed", err.to_string(), None);
234 return 1;
235 }
236 };
237
238 if let Some(parent) = auth_file.parent()
239 && let Err(err) = fs::create_dir_all(parent)
240 {
241 emit_login_error(
242 false,
243 "auth-dir-create-failed",
244 format!(
245 "gemini-login: failed to prepare auth directory {}: {err}",
246 parent.display()
247 ),
248 Some(output::obj(vec![(
249 "auth_file",
250 output::s(auth_file.display().to_string()),
251 )])),
252 );
253 return 1;
254 }
255
256 if auth_file.is_file()
257 && let Err(err) = fs::remove_file(&auth_file)
258 {
259 emit_login_error(
260 false,
261 "auth-file-remove-failed",
262 format!(
263 "gemini-login: failed to remove auth file {}: {err}",
264 auth_file.display()
265 ),
266 Some(output::obj(vec![(
267 "auth_file",
268 output::s(auth_file.display().to_string()),
269 )])),
270 );
271 return 1;
272 }
273
274 if method == LoginMethod::GeminiBrowser {
275 println!("Code Assist login required. Opening authentication page in your browser.");
276 }
277
278 let status = match run_gemini_interactive_login(method, &auth_file) {
279 Ok(status) => status,
280 Err(err) => {
281 let _ = restore_auth_backup(&auth_file, backup.as_deref());
282 emit_login_error(false, err.code, err.message, err.details);
283 return err.exit_code;
284 }
285 };
286
287 if !status.success() {
288 let _ = restore_auth_backup(&auth_file, backup.as_deref());
289 let exit_code = status.code().unwrap_or(1).max(1);
290 emit_login_error(
291 false,
292 "login-failed",
293 format!("gemini-login: login failed for method {}", method.as_str()),
294 Some(output::obj(vec![
295 ("method", output::s(method.as_str())),
296 ("exit_code", output::n(i64::from(exit_code))),
297 ])),
298 );
299 return exit_code;
300 }
301
302 let auth_json = match crate::json::read_json(&auth_file) {
303 Ok(value) => value,
304 Err(err) => {
305 let _ = restore_auth_backup(&auth_file, backup.as_deref());
306 emit_login_error(
307 false,
308 "auth-read-failed",
309 format!(
310 "gemini-login: login completed but failed to read auth file {}: {err}",
311 auth_file.display()
312 ),
313 Some(output::obj(vec![(
314 "auth_file",
315 output::s(auth_file.display().to_string()),
316 )])),
317 );
318 return 1;
319 }
320 };
321
322 let access_token = match access_token_from_json(&auth_json) {
323 Some(token) => token,
324 None => {
325 let _ = restore_auth_backup(&auth_file, backup.as_deref());
326 emit_login_error(
327 false,
328 "missing-access-token",
329 format!(
330 "gemini-login: login completed but auth file is missing access token: {}",
331 auth_file.display()
332 ),
333 Some(output::obj(vec![(
334 "auth_file",
335 output::s(auth_file.display().to_string()),
336 )])),
337 );
338 return 2;
339 }
340 };
341
342 if let Err(err) = fetch_google_userinfo(&access_token) {
343 let _ = restore_auth_backup(&auth_file, backup.as_deref());
344 emit_login_error(false, err.code, err.message, err.details);
345 return err.exit_code;
346 }
347
348 println!("gemini: login complete (method: {})", method.as_str());
349 0
350}
351
352fn backup_auth_file(path: &Path) -> io::Result<Option<Vec<u8>>> {
353 if !path.is_file() {
354 return Ok(None);
355 }
356 fs::read(path).map(Some)
357}
358
359fn restore_auth_backup(path: &Path, backup: Option<&[u8]>) -> io::Result<()> {
360 match backup {
361 Some(contents) => crate::auth::write_atomic(path, contents, crate::auth::SECRET_FILE_MODE),
362 None => {
363 if path.is_file() {
364 fs::remove_file(path)
365 } else {
366 Ok(())
367 }
368 }
369 }
370}
371
372fn run_gemini_interactive_login(
373 method: LoginMethod,
374 auth_file: &Path,
375) -> Result<std::process::ExitStatus, LoginError> {
376 let mut command = Command::new("gemini");
377 command.arg("--prompt-interactive").arg("/quit");
378 if method == LoginMethod::GeminiBrowser {
379 command.arg("--yolo");
381 }
382 command.env("GEMINI_AUTH_FILE", auth_file.to_string_lossy().to_string());
383
384 if method == LoginMethod::GeminiDeviceCode {
385 command.env("NO_BROWSER", "true");
386 } else {
387 command.env_remove("NO_BROWSER");
388 }
389
390 let status = command.status().map_err(|_| LoginError {
391 code: "login-exec-failed",
392 message: format!(
393 "gemini-login: failed to run `gemini` for method {}",
394 method.as_str()
395 ),
396 details: Some(output::obj(vec![("method", output::s(method.as_str()))])),
397 exit_code: 1,
398 })?;
399
400 if !auth_file.is_file() {
401 return Err(LoginError {
402 code: "auth-file-not-found",
403 message: format!(
404 "gemini-login: interactive login did not produce auth file: {}",
405 auth_file.display()
406 ),
407 details: Some(output::obj(vec![
408 ("method", output::s(method.as_str())),
409 ("auth_file", output::s(auth_file.display().to_string())),
410 ("exit_code", output::n(status.code().unwrap_or(0) as i64)),
411 ])),
412 exit_code: 1,
413 });
414 }
415
416 Ok(status)
417}
418
419struct LoginError {
420 code: &'static str,
421 message: String,
422 details: Option<output::JsonValue>,
423 exit_code: i32,
424}
425
426fn fetch_google_userinfo(access_token: &str) -> Result<serde_json::Value, LoginError> {
427 let connect_timeout = env_timeout("GEMINI_LOGIN_CURL_CONNECT_TIMEOUT_SECONDS", 2);
428 let max_time = env_timeout("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", 8);
429
430 let response = Command::new("curl")
431 .arg("-sS")
432 .arg("--connect-timeout")
433 .arg(connect_timeout.to_string())
434 .arg("--max-time")
435 .arg(max_time.to_string())
436 .arg("-H")
437 .arg(format!("Authorization: Bearer {access_token}"))
438 .arg("-H")
439 .arg("Accept: application/json")
440 .arg(GOOGLE_USERINFO_URL)
441 .arg("-w")
442 .arg("\n__HTTP_STATUS__:%{http_code}")
443 .output()
444 .map_err(|_| LoginError {
445 code: "login-request-failed",
446 message: format!("gemini-login: failed to query {GOOGLE_USERINFO_URL}"),
447 details: Some(output::obj(vec![(
448 "endpoint",
449 output::s(GOOGLE_USERINFO_URL),
450 )])),
451 exit_code: 3,
452 })?;
453
454 if !response.status.success() {
455 return Err(LoginError {
456 code: "login-request-failed",
457 message: format!("gemini-login: failed to query {GOOGLE_USERINFO_URL}"),
458 details: Some(output::obj(vec![(
459 "endpoint",
460 output::s(GOOGLE_USERINFO_URL),
461 )])),
462 exit_code: 3,
463 });
464 }
465
466 let response_text = String::from_utf8_lossy(&response.stdout).to_string();
467 let (body, http_status) = split_http_status_marker(&response_text);
468 if http_status != 200 {
469 let summary = http_error_summary(&body);
470 let mut details = vec![
471 ("endpoint".to_string(), output::s(GOOGLE_USERINFO_URL)),
472 ("http_status".to_string(), output::n(http_status as i64)),
473 ];
474 if let Some(summary) = summary {
475 details.push(("summary".to_string(), output::s(summary)));
476 }
477 return Err(LoginError {
478 code: "login-http-error",
479 message: format!(
480 "gemini-login: userinfo request failed (HTTP {http_status}) at {GOOGLE_USERINFO_URL}"
481 ),
482 details: Some(output::obj_dynamic(details)),
483 exit_code: 3,
484 });
485 }
486
487 let json: serde_json::Value = serde_json::from_str(&body).map_err(|_| LoginError {
488 code: "login-invalid-json",
489 message: "gemini-login: userinfo endpoint returned invalid JSON".to_string(),
490 details: Some(output::obj(vec![(
491 "endpoint",
492 output::s(GOOGLE_USERINFO_URL),
493 )])),
494 exit_code: 4,
495 })?;
496 Ok(json)
497}
498
499fn has_refresh_token(auth_file: &Path) -> bool {
500 let value = match crate::json::read_json(auth_file) {
501 Ok(value) => value,
502 Err(_) => return false,
503 };
504 refresh_token_from_json(&value).is_some()
505}
506
507fn access_token_from_json(value: &serde_json::Value) -> Option<String> {
508 crate::json::string_at(value, &["tokens", "access_token"])
509 .or_else(|| crate::json::string_at(value, &["access_token"]))
510}
511
512fn refresh_token_from_json(value: &serde_json::Value) -> Option<String> {
513 crate::json::string_at(value, &["tokens", "refresh_token"])
514 .or_else(|| crate::json::string_at(value, &["refresh_token"]))
515}
516
517fn split_http_status_marker(raw: &str) -> (String, u16) {
518 let marker = "__HTTP_STATUS__:";
519 if let Some(index) = raw.rfind(marker) {
520 let body = raw[..index]
521 .trim_end_matches('\n')
522 .trim_end_matches('\r')
523 .to_string();
524 let status_raw = raw[index + marker.len()..].trim();
525 let status = status_raw.parse::<u16>().unwrap_or(0);
526 (body, status)
527 } else {
528 (raw.to_string(), 0)
529 }
530}
531
532fn http_error_summary(body: &str) -> Option<String> {
533 let value: serde_json::Value = serde_json::from_str(body).ok()?;
534 let mut parts = Vec::new();
535
536 if let Some(error) = value.get("error") {
537 if let Some(error_str) = error.as_str() {
538 if !error_str.is_empty() {
539 parts.push(error_str.to_string());
540 }
541 } else if let Some(error_obj) = error.as_object() {
542 if let Some(status) = error_obj.get("status").and_then(|value| value.as_str())
543 && !status.is_empty()
544 {
545 parts.push(status.to_string());
546 }
547 if let Some(message) = error_obj.get("message").and_then(|value| value.as_str())
548 && !message.is_empty()
549 {
550 parts.push(message.to_string());
551 }
552 }
553 }
554
555 if let Some(desc) = value
556 .get("error_description")
557 .and_then(|value| value.as_str())
558 && !desc.is_empty()
559 {
560 parts.push(desc.to_string());
561 }
562
563 if parts.is_empty() {
564 None
565 } else {
566 Some(parts.join(": "))
567 }
568}
569
570fn env_timeout(key: &str, default: u64) -> u64 {
571 std::env::var(key)
572 .ok()
573 .and_then(|raw| raw.parse::<u64>().ok())
574 .unwrap_or(default)
575}
576
577fn emit_login_error(
578 output_json: bool,
579 code: &str,
580 message: String,
581 details: Option<output::JsonValue>,
582) {
583 if output_json {
584 let _ = output::emit_error("auth login", code, message, details);
585 } else {
586 eprintln!("{message}");
587 }
588}
589
590fn resolve_method(
591 api_key: bool,
592 device_code: bool,
593) -> std::result::Result<LoginMethod, ErrorTriplet> {
594 if api_key && device_code {
595 return Err((
596 64,
597 "gemini-login: --api-key cannot be combined with --device-code".to_string(),
598 Some(output::obj(vec![
599 ("api_key", output::b(true)),
600 ("device_code", output::b(true)),
601 ])),
602 ));
603 }
604
605 if api_key {
606 return Ok(LoginMethod::ApiKey);
607 }
608 if device_code {
609 return Ok(LoginMethod::GeminiDeviceCode);
610 }
611 Ok(LoginMethod::GeminiBrowser)
612}
613
614type ErrorTriplet = (i32, String, Option<output::JsonValue>);
615
616impl LoginMethod {
617 fn as_str(self) -> &'static str {
618 match self {
619 Self::GeminiBrowser => "gemini-browser",
620 Self::GeminiDeviceCode => "gemini-device-code",
621 Self::ApiKey => "api-key",
622 }
623 }
624
625 fn provider(self) -> &'static str {
626 match self {
627 Self::GeminiBrowser | Self::GeminiDeviceCode => "gemini",
628 Self::ApiKey => "gemini-api",
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use std::ffi::OsString;
636 use std::fs;
637 use std::path::{Path, PathBuf};
638 use std::sync::{Mutex, OnceLock};
639
640 use pretty_assertions::assert_eq;
641 use serde_json::json;
642 use tempfile::TempDir;
643
644 use super::{
645 LoginMethod, access_token_from_json, backup_auth_file, env_timeout, fetch_google_userinfo,
646 has_refresh_token, http_error_summary, refresh_token_from_json, resolve_method,
647 restore_auth_backup, run, run_api_key_login, run_gemini_interactive_login,
648 run_oauth_interactive_login, run_oauth_session_check, run_with_json,
649 split_http_status_marker,
650 };
651
652 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
653 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
654 match LOCK.get_or_init(|| Mutex::new(())).lock() {
655 Ok(guard) => guard,
656 Err(poisoned) => poisoned.into_inner(),
657 }
658 }
659
660 struct EnvGuard {
661 key: String,
662 old: Option<OsString>,
663 }
664
665 impl EnvGuard {
666 fn set(key: &str, value: &str) -> Self {
667 let old = std::env::var_os(key);
668 unsafe { std::env::set_var(key, value) };
670 Self {
671 key: key.to_string(),
672 old,
673 }
674 }
675
676 fn unset(key: &str) -> Self {
677 let old = std::env::var_os(key);
678 unsafe { std::env::remove_var(key) };
680 Self {
681 key: key.to_string(),
682 old,
683 }
684 }
685 }
686
687 impl Drop for EnvGuard {
688 fn drop(&mut self) {
689 if let Some(value) = self.old.take() {
690 unsafe { std::env::set_var(&self.key, value) };
692 } else {
693 unsafe { std::env::remove_var(&self.key) };
695 }
696 }
697 }
698
699 fn prepend_path(dir: &Path) -> EnvGuard {
700 let mut value = dir.display().to_string();
701 if let Ok(path) = std::env::var("PATH")
702 && !path.is_empty()
703 {
704 value.push(':');
705 value.push_str(&path);
706 }
707 EnvGuard::set("PATH", &value)
708 }
709
710 #[cfg(unix)]
711 fn write_exe(path: &Path, content: &str) {
712 use std::os::unix::fs::PermissionsExt;
713
714 fs::write(path, content).expect("write executable");
715 let mut perms = fs::metadata(path).expect("metadata").permissions();
716 perms.set_mode(0o755);
717 fs::set_permissions(path, perms).expect("chmod");
718 }
719
720 #[cfg(not(unix))]
721 fn write_exe(path: &Path, content: &str) {
722 fs::write(path, content).expect("write executable");
723 }
724
725 fn write_script(dir: &Path, name: &str, content: &str) -> PathBuf {
726 let path = dir.join(name);
727 write_exe(&path, content);
728 path
729 }
730
731 fn curl_success_script() -> &'static str {
732 r#"#!/bin/sh
733set -eu
734cat <<'EOF'
735{"email":"alpha@example.com"}
736__HTTP_STATUS__:200
737EOF
738"#
739 }
740
741 fn curl_http_error_script() -> &'static str {
742 r#"#!/bin/sh
743set -eu
744cat <<'EOF'
745{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"refresh needed"}
746__HTTP_STATUS__:401
747EOF
748"#
749 }
750
751 fn curl_invalid_json_script() -> &'static str {
752 r#"#!/bin/sh
753set -eu
754cat <<'EOF'
755not-json
756__HTTP_STATUS__:200
757EOF
758"#
759 }
760
761 fn curl_exit_failure_script() -> &'static str {
762 r#"#!/bin/sh
763exit 9
764"#
765 }
766
767 #[test]
768 fn run_delegates_to_run_with_json_non_json_mode() {
769 let _lock = env_lock();
770 let _api = EnvGuard::set("GEMINI_API_KEY", "dummy");
771 let _google = EnvGuard::unset("GOOGLE_API_KEY");
772 assert_eq!(run(true, false), 0);
773 }
774
775 #[test]
776 fn run_with_json_reports_invalid_usage_for_conflicting_flags() {
777 let _lock = env_lock();
778 assert_eq!(run_with_json(true, true, true), 64);
779 }
780
781 #[test]
782 fn run_api_key_login_json_errors_when_keys_are_missing() {
783 let _lock = env_lock();
784 let _api = EnvGuard::set("GEMINI_API_KEY", "");
785 let _google = EnvGuard::set("GOOGLE_API_KEY", "");
786 assert_eq!(run_api_key_login(true), 64);
787 }
788
789 #[test]
790 fn run_api_key_login_uses_google_api_key_when_gemini_key_missing() {
791 let _lock = env_lock();
792 let _api = EnvGuard::set("GEMINI_API_KEY", "");
793 let _google = EnvGuard::set("GOOGLE_API_KEY", "google-key");
794 assert_eq!(run_api_key_login(true), 0);
795 }
796
797 #[test]
798 fn run_oauth_session_check_missing_auth_file_returns_error() {
799 let _lock = env_lock();
800 let temp = TempDir::new().expect("temp dir");
801 let auth_file = temp.path().join("missing-auth.json");
802 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
803 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
804 }
805
806 #[test]
807 fn run_oauth_session_check_invalid_auth_json_returns_error() {
808 let _lock = env_lock();
809 let temp = TempDir::new().expect("temp dir");
810 let auth_file = temp.path().join("oauth.json");
811 fs::write(&auth_file, "{invalid").expect("write auth");
812 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
813 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 1);
814 }
815
816 #[test]
817 fn run_oauth_session_check_missing_access_token_returns_error() {
818 let _lock = env_lock();
819 let temp = TempDir::new().expect("temp dir");
820 let auth_file = temp.path().join("oauth.json");
821 fs::write(&auth_file, "{}").expect("write auth");
822 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
823 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 2);
824 }
825
826 #[test]
827 fn run_oauth_session_check_http_error_returns_error() {
828 let _lock = env_lock();
829 let temp = TempDir::new().expect("temp dir");
830 let bin_dir = temp.path().join("bin");
831 fs::create_dir_all(&bin_dir).expect("create bin");
832 write_script(&bin_dir, "curl", curl_http_error_script());
833
834 let auth_file = temp.path().join("oauth.json");
835 fs::write(&auth_file, r#"{"access_token":"tok"}"#).expect("write auth");
836 let _path = prepend_path(&bin_dir);
837 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
838 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 3);
839 }
840
841 #[test]
842 fn run_oauth_session_check_invalid_userinfo_json_returns_error() {
843 let _lock = env_lock();
844 let temp = TempDir::new().expect("temp dir");
845 let bin_dir = temp.path().join("bin");
846 fs::create_dir_all(&bin_dir).expect("create bin");
847 write_script(&bin_dir, "curl", curl_invalid_json_script());
848
849 let auth_file = temp.path().join("oauth.json");
850 fs::write(&auth_file, r#"{"access_token":"tok"}"#).expect("write auth");
851 let _path = prepend_path(&bin_dir);
852 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
853 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 4);
854 }
855
856 #[test]
857 fn run_oauth_session_check_success_supports_nested_tokens() {
858 let _lock = env_lock();
859 let temp = TempDir::new().expect("temp dir");
860 let bin_dir = temp.path().join("bin");
861 fs::create_dir_all(&bin_dir).expect("create bin");
862 write_script(&bin_dir, "curl", curl_success_script());
863
864 let auth_file = temp.path().join("oauth.json");
865 fs::write(
866 &auth_file,
867 r#"{"tokens":{"access_token":"tok","refresh_token":"refresh-token"}}"#,
868 )
869 .expect("write auth");
870 let _path = prepend_path(&bin_dir);
871 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
872 assert_eq!(run_oauth_session_check(LoginMethod::GeminiBrowser, true), 0);
873 }
874
875 #[test]
876 fn run_oauth_interactive_login_success_device_code_returns_zero() {
877 let _lock = env_lock();
878 let temp = TempDir::new().expect("temp dir");
879 let bin_dir = temp.path().join("bin");
880 fs::create_dir_all(&bin_dir).expect("create bin");
881 write_script(&bin_dir, "curl", curl_success_script());
882 write_script(
883 &bin_dir,
884 "gemini",
885 r#"#!/bin/sh
886set -eu
887[ "${NO_BROWSER:-}" = "true" ]
888cat > "$GEMINI_AUTH_FILE" <<'EOF'
889{"access_token":"new-token"}
890EOF
891"#,
892 );
893
894 let auth_file = temp.path().join("oauth.json");
895 fs::write(&auth_file, r#"{"access_token":"old-token"}"#).expect("write auth");
896 let _path = prepend_path(&bin_dir);
897 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
898 assert_eq!(
899 run_oauth_interactive_login(LoginMethod::GeminiDeviceCode),
900 0
901 );
902 let updated = fs::read_to_string(&auth_file).expect("read auth");
903 assert!(updated.contains("new-token"));
904 }
905
906 #[test]
907 fn run_oauth_interactive_login_non_zero_status_restores_backup() {
908 let _lock = env_lock();
909 let temp = TempDir::new().expect("temp dir");
910 let bin_dir = temp.path().join("bin");
911 fs::create_dir_all(&bin_dir).expect("create bin");
912 write_script(
913 &bin_dir,
914 "gemini",
915 r#"#!/bin/sh
916set -eu
917cat > "$GEMINI_AUTH_FILE" <<'EOF'
918{"access_token":"new-token"}
919EOF
920exit 7
921"#,
922 );
923
924 let auth_file = temp.path().join("oauth.json");
925 let original = r#"{"access_token":"old-token"}"#;
926 fs::write(&auth_file, original).expect("write auth");
927 let _path = prepend_path(&bin_dir);
928 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
929 assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 7);
930 assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
931 }
932
933 #[test]
934 fn run_oauth_interactive_login_missing_token_restores_backup() {
935 let _lock = env_lock();
936 let temp = TempDir::new().expect("temp dir");
937 let bin_dir = temp.path().join("bin");
938 fs::create_dir_all(&bin_dir).expect("create bin");
939 write_script(
940 &bin_dir,
941 "gemini",
942 r#"#!/bin/sh
943set -eu
944cat > "$GEMINI_AUTH_FILE" <<'EOF'
945{}
946EOF
947"#,
948 );
949
950 let auth_file = temp.path().join("oauth.json");
951 let original = r#"{"access_token":"old-token"}"#;
952 fs::write(&auth_file, original).expect("write auth");
953 let _path = prepend_path(&bin_dir);
954 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
955 assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 2);
956 assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
957 }
958
959 #[test]
960 fn run_oauth_interactive_login_userinfo_error_restores_backup() {
961 let _lock = env_lock();
962 let temp = TempDir::new().expect("temp dir");
963 let bin_dir = temp.path().join("bin");
964 fs::create_dir_all(&bin_dir).expect("create bin");
965 write_script(&bin_dir, "curl", curl_http_error_script());
966 write_script(
967 &bin_dir,
968 "gemini",
969 r#"#!/bin/sh
970set -eu
971cat > "$GEMINI_AUTH_FILE" <<'EOF'
972{"access_token":"new-token"}
973EOF
974"#,
975 );
976
977 let auth_file = temp.path().join("oauth.json");
978 let original = r#"{"access_token":"old-token"}"#;
979 fs::write(&auth_file, original).expect("write auth");
980 let _path = prepend_path(&bin_dir);
981 let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file.display().to_string());
982 assert_eq!(run_oauth_interactive_login(LoginMethod::GeminiBrowser), 3);
983 assert_eq!(fs::read_to_string(&auth_file).expect("read auth"), original);
984 }
985
986 #[test]
987 fn run_gemini_interactive_login_errors_when_auth_file_not_created() {
988 let _lock = env_lock();
989 let temp = TempDir::new().expect("temp dir");
990 let bin_dir = temp.path().join("bin");
991 fs::create_dir_all(&bin_dir).expect("create bin");
992 write_script(
993 &bin_dir,
994 "gemini",
995 r#"#!/bin/sh
996exit 0
997"#,
998 );
999 let _path = prepend_path(&bin_dir);
1000 let auth_file = temp.path().join("missing-output.json");
1001 let err = run_gemini_interactive_login(LoginMethod::GeminiBrowser, &auth_file)
1002 .expect_err("missing output file should fail");
1003 assert_eq!(err.code, "auth-file-not-found");
1004 assert_eq!(err.exit_code, 1);
1005 }
1006
1007 #[test]
1008 fn fetch_google_userinfo_handles_command_failures_and_invalid_json() {
1009 let _lock = env_lock();
1010 let temp = TempDir::new().expect("temp dir");
1011 let bin_dir = temp.path().join("bin");
1012 fs::create_dir_all(&bin_dir).expect("create bin");
1013
1014 write_script(&bin_dir, "curl", curl_exit_failure_script());
1015 let _path = prepend_path(&bin_dir);
1016 let request_err =
1017 fetch_google_userinfo("token").expect_err("non-zero curl exit should be an error");
1018 assert_eq!(request_err.code, "login-request-failed");
1019 assert_eq!(request_err.exit_code, 3);
1020
1021 write_script(&bin_dir, "curl", curl_invalid_json_script());
1022 let invalid_json_err =
1023 fetch_google_userinfo("token").expect_err("invalid payload should fail");
1024 assert_eq!(invalid_json_err.code, "login-invalid-json");
1025 assert_eq!(invalid_json_err.exit_code, 4);
1026 }
1027
1028 #[test]
1029 fn split_http_status_marker_and_error_summary_are_stable() {
1030 let (body, status) = split_http_status_marker("{\"ok\":true}\n__HTTP_STATUS__:200");
1031 assert_eq!(body, "{\"ok\":true}");
1032 assert_eq!(status, 200);
1033
1034 let (body_without_marker, status_without_marker) = split_http_status_marker("plain-body");
1035 assert_eq!(body_without_marker, "plain-body");
1036 assert_eq!(status_without_marker, 0);
1037
1038 let summary = http_error_summary(
1039 r#"{"error":{"status":"UNAUTHENTICATED","message":"token expired"},"error_description":"reauth"}"#,
1040 );
1041 assert_eq!(
1042 summary,
1043 Some("UNAUTHENTICATED: token expired: reauth".to_string())
1044 );
1045 }
1046
1047 #[test]
1048 fn env_timeout_and_token_helpers_cover_defaults_and_nested_values() {
1049 let _lock = env_lock();
1050 let _timeout = EnvGuard::set("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", "11");
1051 assert_eq!(env_timeout("GEMINI_LOGIN_CURL_MAX_TIME_SECONDS", 8), 11);
1052 assert_eq!(env_timeout("GEMINI_LOGIN_CURL_UNKNOWN", 5), 5);
1053
1054 let nested =
1055 json!({"tokens":{"access_token":"nested-access","refresh_token":"nested-refresh"}});
1056 assert_eq!(
1057 access_token_from_json(&nested),
1058 Some("nested-access".to_string())
1059 );
1060 assert_eq!(
1061 refresh_token_from_json(&nested),
1062 Some("nested-refresh".to_string())
1063 );
1064
1065 let top_level = json!({"access_token":"top-access","refresh_token":"top-refresh"});
1066 assert_eq!(
1067 access_token_from_json(&top_level),
1068 Some("top-access".to_string())
1069 );
1070 assert_eq!(
1071 refresh_token_from_json(&top_level),
1072 Some("top-refresh".to_string())
1073 );
1074 }
1075
1076 #[test]
1077 fn backup_restore_and_refresh_detection_behave_as_expected() {
1078 let _lock = env_lock();
1079 let temp = TempDir::new().expect("temp dir");
1080 let auth_file = temp.path().join("oauth.json");
1081
1082 assert_eq!(
1083 backup_auth_file(&auth_file).expect("backup missing file"),
1084 None
1085 );
1086 assert_eq!(has_refresh_token(&auth_file), false);
1087
1088 fs::write(&auth_file, r#"{"refresh_token":"refresh"}"#).expect("write auth");
1089 assert_eq!(has_refresh_token(&auth_file), true);
1090
1091 let backup = backup_auth_file(&auth_file).expect("backup existing file");
1092 fs::write(&auth_file, r#"{"access_token":"mutated"}"#).expect("mutate auth");
1093 restore_auth_backup(&auth_file, backup.as_deref()).expect("restore backup");
1094 assert_eq!(
1095 fs::read_to_string(&auth_file).expect("read restored auth"),
1096 r#"{"refresh_token":"refresh"}"#
1097 );
1098
1099 restore_auth_backup(&auth_file, None).expect("remove backup target");
1100 assert_eq!(auth_file.exists(), false);
1101 }
1102
1103 #[test]
1104 fn resolve_method_defaults_to_gemini_browser() {
1105 assert_eq!(
1106 resolve_method(false, false).expect("method"),
1107 LoginMethod::GeminiBrowser
1108 );
1109 }
1110
1111 #[test]
1112 fn resolve_method_selects_device_code_and_api_key() {
1113 assert_eq!(
1114 resolve_method(false, true).expect("method"),
1115 LoginMethod::GeminiDeviceCode
1116 );
1117 assert_eq!(
1118 resolve_method(true, false).expect("method"),
1119 LoginMethod::ApiKey
1120 );
1121 }
1122
1123 #[test]
1124 fn resolve_method_rejects_conflicting_flags() {
1125 let err = resolve_method(true, true).expect_err("conflict should fail");
1126 assert_eq!(err.0, 64);
1127 assert!(err.1.contains("--api-key"));
1128 }
1129
1130 #[test]
1131 fn login_method_strings_and_providers_are_stable() {
1132 assert_eq!(LoginMethod::GeminiBrowser.as_str(), "gemini-browser");
1133 assert_eq!(LoginMethod::GeminiDeviceCode.as_str(), "gemini-device-code");
1134 assert_eq!(LoginMethod::ApiKey.as_str(), "api-key");
1135
1136 assert_eq!(LoginMethod::GeminiBrowser.provider(), "gemini");
1137 assert_eq!(LoginMethod::GeminiDeviceCode.provider(), "gemini");
1138 assert_eq!(LoginMethod::ApiKey.provider(), "gemini-api");
1139 }
1140}