1use std::net::{IpAddr, Ipv4Addr, SocketAddr};
14use std::path::Path;
15use std::process::{Command, Stdio};
16use std::time::Duration;
17
18use anyhow::{Context, Result};
19use base64::Engine as _;
20use chrono::{DateTime, TimeDelta, Utc};
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::net::TcpListener;
25use url::Url;
26
27use crate::drive::account::{self, ResolvedAccount};
28use crate::drive::chrome_profile;
29use crate::drive::error::{DriveError, GrantContext};
30use crate::request_log;
31use crate::utils::browser_command::split_browser_command;
32use crate::utils::env::SystemEnv;
33use crate::utils::secret::Secret;
34use crate::utils::secret_env::{secret_var, secret_var_is_set};
35use crate::utils::settings::{
36 account_secret, active_profile_from, DriveAccountSettings, DriveSettings, Settings,
37};
38
39pub const DRIVE_CLIENT_ID: &str = "DRIVE_CLIENT_ID";
42pub const DRIVE_CLIENT_SECRET: &str = "DRIVE_CLIENT_SECRET";
45pub const DRIVE_REFRESH_TOKEN: &str = "DRIVE_REFRESH_TOKEN";
47pub const DRIVE_SCOPE: &str = "DRIVE_SCOPE";
49pub const DRIVE_API_URL: &str = "DRIVE_API_URL";
61
62const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
65const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
67pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/drive.readonly";
73pub const SCOPE_METADATA: &str = "https://www.googleapis.com/auth/drive.metadata";
76pub const SCOPE_FILE: &str = "https://www.googleapis.com/auth/drive.file";
84pub const SCOPE_FULL: &str = "https://www.googleapis.com/auth/drive";
91
92const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
94const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
97const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub struct DriveGrantedScopes {
117 pub readonly: bool,
119 pub metadata: bool,
122 pub file: bool,
124 pub full: bool,
126}
127
128impl DriveGrantedScopes {
129 pub const READONLY: Self = Self {
131 readonly: true,
132 metadata: false,
133 file: false,
134 full: false,
135 };
136 pub const METADATA: Self = Self {
139 readonly: true,
140 metadata: true,
141 file: false,
142 full: false,
143 };
144
145 #[must_use]
152 pub fn as_str(self) -> String {
153 [
154 self.readonly.then_some(SCOPE_READONLY),
155 self.metadata.then_some(SCOPE_METADATA),
156 self.file.then_some(SCOPE_FILE),
157 self.full.then_some(SCOPE_FULL),
158 ]
159 .into_iter()
160 .flatten()
161 .collect::<Vec<_>>()
162 .join(" ")
163 }
164
165 #[must_use]
181 pub fn summary(self) -> String {
182 let names: Vec<&str> = [
183 self.readonly.then_some("drive.readonly"),
184 self.metadata.then_some("drive.metadata"),
185 self.file.then_some("drive.file"),
186 self.full.then_some("drive"),
187 ]
188 .into_iter()
189 .flatten()
190 .collect();
191 if names.is_empty() {
192 return "(none)".to_string();
193 }
194 names.join(", ")
195 }
196
197 #[must_use]
206 pub fn from_granted(granted: &str) -> Option<Self> {
207 let tokens: Vec<&str> = granted.split_whitespace().collect();
208 let scopes = Self {
209 readonly: tokens.contains(&SCOPE_READONLY),
210 metadata: tokens.contains(&SCOPE_METADATA),
211 file: tokens.contains(&SCOPE_FILE),
212 full: tokens.contains(&SCOPE_FULL),
213 };
214 (scopes != Self::default()).then_some(scopes)
215 }
216
217 #[must_use]
220 pub fn allows_rename_move(self) -> bool {
221 self.metadata
222 }
223
224 #[must_use]
227 pub fn allows_create_or_upload(self) -> bool {
228 self.file || self.full
229 }
230
231 #[must_use]
235 pub fn allows_edit_arbitrary_content(self) -> bool {
236 self.full
237 }
238
239 #[must_use]
241 pub fn allows_write(self) -> bool {
242 self.metadata || self.file || self.full
243 }
244}
245
246#[derive(Debug, Clone)]
248pub struct DriveCredentials {
249 pub client_id: String,
252 pub client_secret: Secret,
254 pub refresh_token: Secret,
256 pub scope: DriveGrantedScopes,
258}
259
260#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
262pub struct DriveAuthStatus {
263 pub has_client_id: bool,
265 pub has_client_secret: bool,
267 pub has_refresh_token: bool,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub scope: Option<String>,
272}
273
274pub(crate) fn resolve(drive: &DriveSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
279 let explicit = fold_explicit(explicit);
280 account::resolve_account(&SystemEnv, drive, explicit.as_deref())
281}
282
283pub(crate) fn resolve_for_write(
287 drive: &DriveSettings,
288 explicit: Option<&str>,
289) -> Result<ResolvedAccount> {
290 let explicit = fold_explicit(explicit);
291 account::resolve_account_for_write(&SystemEnv, drive, explicit.as_deref())
292}
293
294fn fold_explicit(explicit: Option<&str>) -> Option<String> {
298 explicit
299 .map(str::to_string)
300 .or_else(|| account::active_drive_account_from(&SystemEnv))
301}
302
303pub(crate) fn resolve_browser_config_for(
311 drive: &DriveSettings,
312 explicit: Option<&str>,
313) -> Result<BrowserConfig> {
314 match resolve_for_write(drive, explicit)? {
315 ResolvedAccount::Unconfigured => Ok(BrowserConfig::default()),
316 ResolvedAccount::Named(name) => build_browser_config(
317 drive.accounts.get(&name),
318 chrome_profile::resolve_launch_command,
319 ),
320 }
321}
322
323fn build_browser_config(
336 account: Option<&DriveAccountSettings>,
337 resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
338) -> Result<BrowserConfig> {
339 let Some(account) = account else {
340 return Ok(BrowserConfig::default());
341 };
342
343 if let Some(command) = account
344 .browser_command
345 .as_deref()
346 .map(str::trim)
347 .filter(|command| !command.is_empty())
348 {
349 return Ok(BrowserConfig {
350 launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
351 ..BrowserConfig::default()
352 });
353 }
354
355 if account.chrome_profile_from_email {
356 if let Some(email) = account.email_address.as_deref() {
357 if let Some(args) = resolve_chrome_profile(email) {
358 return Ok(BrowserConfig {
359 launch: BrowserLaunch::Command(args),
360 ..BrowserConfig::default()
361 });
362 }
363 } else {
364 tracing::info!(
365 "chrome_profile_from_email is set but email_address is not; \
366 falling back to the default browser"
367 );
368 }
369 }
370
371 Ok(BrowserConfig::default())
372}
373
374pub fn load_credentials() -> Result<DriveCredentials> {
378 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
379}
380
381pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<DriveCredentials> {
391 let settings = Settings::load_or_warn_default();
392 match resolve(&settings.drive, explicit)? {
393 ResolvedAccount::Unconfigured => {
394 let profile = active_profile_from(&SystemEnv);
395 load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
396 settings,
397 profile.as_deref(),
398 ))
399 }
400 ResolvedAccount::Named(name) => load_named_credentials(&settings.drive, &name),
401 }
402}
403
404fn load_named_credentials(drive: &DriveSettings, name: &str) -> Result<DriveCredentials> {
408 let account = drive
409 .accounts
410 .get(name)
411 .ok_or(DriveError::CredentialsNotFound)?;
412 let client_id = account
413 .client_id
414 .clone()
415 .ok_or(DriveError::CredentialsNotFound)?;
416 let client_secret = account_secret(
417 "drive",
418 name,
419 "client_secret",
420 account.client_secret.as_deref(),
421 account.client_secret_file.as_deref(),
422 )?
423 .ok_or(DriveError::CredentialsNotFound)?;
424 let refresh_token = account_secret(
425 "drive",
426 name,
427 "refresh_token",
428 account.refresh_token.as_deref(),
429 account.refresh_token_file.as_deref(),
430 )?
431 .ok_or(DriveError::CredentialsNotFound)?;
432 let scope = account
433 .scope
434 .as_deref()
435 .and_then(DriveGrantedScopes::from_granted)
436 .unwrap_or_default();
437
438 Ok(DriveCredentials {
439 client_id,
440 client_secret,
441 refresh_token,
442 scope,
443 })
444}
445
446pub(crate) fn load_credentials_with(
452 env: &impl crate::utils::env::EnvSource,
453) -> Result<DriveCredentials> {
454 let client_id = env
455 .var(DRIVE_CLIENT_ID)
456 .ok_or(DriveError::CredentialsNotFound)?;
457 let client_secret =
458 secret_var(env, DRIVE_CLIENT_SECRET)?.ok_or(DriveError::CredentialsNotFound)?;
459 let refresh_token =
460 secret_var(env, DRIVE_REFRESH_TOKEN)?.ok_or(DriveError::CredentialsNotFound)?;
461 let scope = env
470 .var(DRIVE_SCOPE)
471 .and_then(|s| DriveGrantedScopes::from_granted(&s))
472 .unwrap_or_default();
473
474 Ok(DriveCredentials {
475 client_id,
476 client_secret,
477 refresh_token,
478 scope,
479 })
480}
481
482pub fn status() -> DriveAuthStatus {
487 status_with(&crate::utils::settings::SettingsEnv::load())
488}
489
490pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> DriveAuthStatus {
492 DriveAuthStatus {
493 has_client_id: env.var(DRIVE_CLIENT_ID).is_some(),
494 has_client_secret: secret_var_is_set(env, DRIVE_CLIENT_SECRET),
495 has_refresh_token: secret_var_is_set(env, DRIVE_REFRESH_TOKEN),
496 scope: env.var(DRIVE_SCOPE),
497 }
498}
499
500#[cfg(feature = "mcp")]
511pub(crate) fn status_for(explicit: Option<&str>) -> Result<DriveAuthStatus> {
512 let settings = Settings::load_or_warn_default();
513 match resolve(&settings.drive, explicit)? {
514 ResolvedAccount::Unconfigured => {
515 let profile = active_profile_from(&SystemEnv);
516 Ok(status_with(
517 &crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
518 ))
519 }
520 ResolvedAccount::Named(name) => Ok(status_from_named(&settings.drive, &name)),
521 }
522}
523
524#[cfg(feature = "mcp")]
530fn status_from_named(drive: &DriveSettings, name: &str) -> DriveAuthStatus {
531 let account = drive.accounts.get(name);
532 DriveAuthStatus {
533 has_client_id: account.is_some_and(|a| a.client_id.is_some()),
534 has_client_secret: account.is_some_and(|a| {
535 crate::utils::settings::account_secret_is_set(
536 a.client_secret.as_deref(),
537 a.client_secret_file.as_deref(),
538 )
539 }),
540 has_refresh_token: account.is_some_and(|a| {
541 crate::utils::settings::account_secret_is_set(
542 a.refresh_token.as_deref(),
543 a.refresh_token_file.as_deref(),
544 )
545 }),
546 scope: account.and_then(|a| a.scope.clone()),
547 }
548}
549
550pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
556 let settings = Settings::load_or_warn_default();
557 if settings
558 .drive
559 .accounts
560 .get(name)
561 .is_some_and(|account| account.email_address.is_some())
562 {
563 return Ok(());
564 }
565 Settings::upsert_drive_account(
566 &Settings::get_settings_path()?,
567 name,
568 &[(
569 "email_address",
570 serde_json::Value::String(email.to_string()),
571 )],
572 )
573}
574
575pub fn save_credentials(credentials: &DriveCredentials) -> Result<()> {
580 save_credentials_to(
581 &Settings::get_settings_path()?,
582 active_profile_from(&SystemEnv).as_deref(),
583 credentials,
584 )
585}
586
587pub(crate) fn save_credentials_to(
590 settings_path: &Path,
591 profile: Option<&str>,
592 credentials: &DriveCredentials,
593) -> Result<()> {
594 let scope_str = credentials.scope.as_str();
595 Settings::upsert_env_vars_in(
596 settings_path,
597 profile,
598 &[
599 (DRIVE_CLIENT_ID, credentials.client_id.as_str()),
600 (
601 DRIVE_CLIENT_SECRET,
602 credentials.client_secret.expose_secret(),
603 ),
604 (
605 DRIVE_REFRESH_TOKEN,
606 credentials.refresh_token.expose_secret(),
607 ),
608 (DRIVE_SCOPE, scope_str.as_str()),
609 ],
610 )
611}
612
613fn named_account_vars(credentials: &DriveCredentials) -> [(&str, serde_json::Value); 4] {
617 [
618 (
619 "client_id",
620 serde_json::Value::String(credentials.client_id.clone()),
621 ),
622 (
623 "client_secret",
624 serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
625 ),
626 (
627 "refresh_token",
628 serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
629 ),
630 (
631 "scope",
632 serde_json::Value::String(credentials.scope.as_str()),
633 ),
634 ]
635}
636
637pub fn remove_credentials() -> Result<bool> {
643 remove_credentials_at(
644 &Settings::get_settings_path()?,
645 active_profile_from(&SystemEnv).as_deref(),
646 )
647}
648
649pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
652 Settings::remove_env_vars_in(
653 settings_path,
654 profile,
655 &[
656 DRIVE_CLIENT_ID,
657 DRIVE_CLIENT_SECRET,
658 DRIVE_REFRESH_TOKEN,
659 DRIVE_SCOPE,
660 ],
661 )
662}
663
664pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
669 let settings = Settings::load_or_warn_default();
670 match resolve(&settings.drive, explicit)? {
671 ResolvedAccount::Unconfigured => remove_credentials_at(
672 &Settings::get_settings_path()?,
673 active_profile_from(&SystemEnv).as_deref(),
674 ),
675 ResolvedAccount::Named(name) => {
676 Settings::remove_drive_account(&Settings::get_settings_path()?, &name)
677 }
678 }
679}
680
681#[derive(Clone, Debug, Default)]
692pub enum BrowserLaunch {
693 #[default]
695 Auto,
696 Command(Vec<String>),
700 Manual,
703}
704
705#[derive(Clone, Debug)]
707pub struct BrowserConfig {
708 pub launch: BrowserLaunch,
710 pub callback_addr: IpAddr,
712 pub callback_port: u16,
714}
715
716impl Default for BrowserConfig {
717 fn default() -> Self {
718 Self {
719 launch: BrowserLaunch::Auto,
720 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
721 callback_port: 0,
722 }
723 }
724}
725
726#[allow(clippy::literal_string_with_formatting_args)]
729fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
730 match launch {
731 BrowserLaunch::Manual => {
732 tracing::info!("Open this URL in a browser to sign in to Drive:\n{url}");
733 Ok(())
734 }
735 BrowserLaunch::Command(args) => {
736 let mut parts = args.iter();
737 let program = parts
738 .next()
739 .ok_or_else(|| DriveError::InvalidBrowserCommand("empty browser command".into()))?;
740 let mut command = Command::new(program);
741 let mut placed = false;
742 for arg in parts {
743 if arg.contains("{url}") {
744 command.arg(arg.replace("{url}", url));
745 placed = true;
746 } else {
747 command.arg(arg);
748 }
749 }
750 if !placed {
751 command.arg(url);
752 }
753 spawn_detached(command)
754 }
755 BrowserLaunch::Auto => {
756 let program = if cfg!(target_os = "macos") {
757 "open"
758 } else if cfg!(target_os = "windows") {
759 "explorer"
760 } else {
761 "xdg-open"
762 };
763 let mut command = Command::new(program);
764 command.arg(url);
765 spawn_detached(command)
766 }
767 }
768}
769
770fn spawn_detached(mut command: Command) -> Result<()> {
772 command
773 .stdin(Stdio::null())
774 .stdout(Stdio::null())
775 .stderr(Stdio::null())
776 .spawn()
777 .map(|_| ())
778 .context("Failed to launch the browser")
779}
780
781struct PendingLogin {
786 state: String,
787 code_verifier: String,
788}
789
790fn generate_pending_login() -> PendingLogin {
791 PendingLogin {
792 state: crate::browser::auth::generate_token(),
793 code_verifier: crate::browser::auth::generate_token(),
794 }
795}
796
797fn code_challenge(code_verifier: &str) -> String {
800 let digest = Sha256::digest(code_verifier.as_bytes());
801 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
802}
803
804fn build_authorization_url(
805 client_id: &str,
806 redirect_uri: &str,
807 scope: DriveGrantedScopes,
808 state: &str,
809 code_challenge: &str,
810) -> Result<Url> {
811 let mut url =
812 Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Drive authorization endpoint")?;
813 let requested_scope = scope.as_str();
818 url.query_pairs_mut()
819 .append_pair("client_id", client_id)
820 .append_pair("redirect_uri", redirect_uri)
821 .append_pair("response_type", "code")
822 .append_pair("scope", &requested_scope)
823 .append_pair("state", state)
824 .append_pair("code_challenge", code_challenge)
825 .append_pair("code_challenge_method", "S256")
826 .append_pair("access_type", "offline")
827 .append_pair("prompt", "consent");
831 Ok(url)
832}
833
834#[derive(Debug)]
839pub(crate) struct CallbackResult {
840 code: Option<String>,
841 state: Option<String>,
842 error: Option<String>,
843 error_description: Option<String>,
844}
845
846pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
850 let listener = TcpListener::bind(SocketAddr::new(
851 browser.callback_addr,
852 browser.callback_port,
853 ))
854 .await
855 .context("Failed to start the local OAuth callback listener")?;
856 let port = listener
857 .local_addr()
858 .context("Failed to read the callback listener's port")?
859 .port();
860 Ok((listener, port))
861}
862
863pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
866 wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
867}
868
869pub(crate) async fn wait_for_callback_with_timeout(
876 listener: TcpListener,
877 timeout: Duration,
878) -> Result<CallbackResult> {
879 let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
880 .await
881 .map_err(|_| DriveError::CallbackTimeout(timeout.as_secs()))?
882 .context("Failed to accept the browser's callback connection")?;
883
884 let mut buf = vec![0u8; 8192];
885 let n = stream
886 .read(&mut buf)
887 .await
888 .context("Failed to read the callback request")?;
889 let request = String::from_utf8_lossy(&buf[..n]);
890
891 let result = parse_callback(&request).ok_or(DriveError::MalformedCallback)?;
892 tracing::info!("Drive OAuth callback received");
893
894 let body = if result.error.is_some() {
895 "<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
896 } else {
897 "<html><body>Drive sign-in complete. You can close this tab.</body></html>"
898 };
899 let response =
900 format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
901 let _ = stream.write_all(response.as_bytes()).await;
902 let _ = stream.flush().await;
903
904 Ok(result)
905}
906
907fn parse_callback(request: &str) -> Option<CallbackResult> {
910 let first_line = request.lines().next()?;
911 let path = first_line.split_whitespace().nth(1)?; let query = path.split_once('?')?.1;
913
914 let mut result = CallbackResult {
915 code: None,
916 state: None,
917 error: None,
918 error_description: None,
919 };
920 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
921 match key.as_ref() {
922 "code" => result.code = Some(value.into_owned()),
923 "state" => result.state = Some(value.into_owned()),
924 "error" => result.error = Some(value.into_owned()),
925 "error_description" => result.error_description = Some(value.into_owned()),
926 _ => {}
927 }
928 }
929 Some(result)
930}
931
932#[derive(Debug, Deserialize)]
935struct TokenResponse {
936 access_token: String,
937 #[serde(default)]
938 refresh_token: Option<String>,
939 expires_in: i64,
940 #[serde(default)]
941 scope: Option<String>,
942}
943
944#[derive(Debug, Deserialize)]
945struct TokenErrorResponse {
946 error: String,
947 #[serde(default)]
948 error_description: Option<String>,
949}
950
951async fn exchange_code_for_tokens(
952 http: &reqwest::Client,
953 token_endpoint: &str,
954 client_id: &str,
955 client_secret: &str,
956 code: &str,
957 code_verifier: &str,
958 redirect_uri: &str,
959) -> Result<TokenResponse> {
960 let params = [
961 ("grant_type", "authorization_code"),
962 ("code", code),
963 ("client_id", client_id),
964 ("client_secret", client_secret),
965 ("redirect_uri", redirect_uri),
966 ("code_verifier", code_verifier),
967 ];
968 post_token_request(http, token_endpoint, ¶ms, GrantContext::CodeExchange).await
969}
970
971async fn refresh_access_token(
972 http: &reqwest::Client,
973 token_endpoint: &str,
974 client_id: &str,
975 client_secret: &str,
976 refresh_token: &str,
977) -> Result<TokenResponse> {
978 let params = [
979 ("grant_type", "refresh_token"),
980 ("refresh_token", refresh_token),
981 ("client_id", client_id),
982 ("client_secret", client_secret),
983 ];
984 post_token_request(http, token_endpoint, ¶ms, GrantContext::Refresh).await
985}
986
987async fn post_token_request(
991 http: &reqwest::Client,
992 token_endpoint: &str,
993 params: &[(&str, &str)],
994 context: GrantContext,
995) -> Result<TokenResponse> {
996 let started = std::time::Instant::now();
997 let result = http.post(token_endpoint).form(params).send().await;
998 request_log::record_http_result("drive", "POST", token_endpoint, started, &result);
999 let response = result.context("Failed to send token request to Google")?;
1000
1001 if !response.status().is_success() {
1002 let body = response.text().await.unwrap_or_default();
1003 if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
1004 if err.error == "invalid_grant" {
1005 return Err(DriveError::InvalidGrant(context).into());
1006 }
1007 return Err(anyhow::anyhow!(
1008 "Google token endpoint rejected the request: {} ({})",
1009 err.error,
1010 err.error_description.unwrap_or_default()
1011 ));
1012 }
1013 return Err(anyhow::anyhow!(
1014 "Google token endpoint returned an unparsable error body: {body}"
1015 ));
1016 }
1017
1018 response
1019 .json::<TokenResponse>()
1020 .await
1021 .context("Failed to parse Google's token response")
1022}
1023
1024struct TokenState {
1028 access_token: Secret,
1029 expires_at: DateTime<Utc>,
1030}
1031
1032pub struct DriveSession {
1042 http: reqwest::Client,
1043 client_id: String,
1044 client_secret: Secret,
1045 refresh_token: Secret,
1046 token_endpoint: String,
1047 state: tokio::sync::Mutex<TokenState>,
1048}
1049
1050impl DriveSession {
1051 pub(crate) fn new(http: reqwest::Client, credentials: &DriveCredentials) -> Self {
1053 Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
1054 }
1055
1056 pub(crate) fn new_with_token_endpoint(
1059 http: reqwest::Client,
1060 credentials: &DriveCredentials,
1061 token_endpoint: &str,
1062 ) -> Self {
1063 Self {
1064 http,
1065 client_id: credentials.client_id.clone(),
1066 client_secret: credentials.client_secret.clone(),
1067 refresh_token: credentials.refresh_token.clone(),
1068 token_endpoint: token_endpoint.to_string(),
1069 state: tokio::sync::Mutex::new(TokenState {
1070 access_token: Secret::new(""),
1071 expires_at: DateTime::<Utc>::MIN_UTC,
1075 }),
1076 }
1077 }
1078
1079 pub(crate) async fn access_token(&self) -> Result<Secret> {
1082 let mut state = self.state.lock().await;
1083 if Utc::now() + REFRESH_SKEW >= state.expires_at {
1084 self.refresh_locked(&mut state).await?;
1085 }
1086 Ok(state.access_token.clone())
1087 }
1088
1089 pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
1094 let mut state = self.state.lock().await;
1095 if state.access_token != *observed {
1096 return Ok(state.access_token.clone());
1097 }
1098 self.refresh_locked(&mut state).await?;
1099 Ok(state.access_token.clone())
1100 }
1101
1102 async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
1103 let response = refresh_access_token(
1104 &self.http,
1105 &self.token_endpoint,
1106 &self.client_id,
1107 self.client_secret.expose_secret(),
1108 self.refresh_token.expose_secret(),
1109 )
1110 .await?;
1111 state.access_token = response.access_token.into();
1112 let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
1113 state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
1114 Ok(())
1115 }
1116}
1117
1118pub async fn login(
1123 client_id: &str,
1124 client_secret: &Secret,
1125 scope: DriveGrantedScopes,
1126 browser: &BrowserConfig,
1127) -> Result<DriveAuthStatus> {
1128 login_to(
1129 &Settings::get_settings_path()?,
1130 active_profile_from(&SystemEnv).as_deref(),
1131 client_id,
1132 client_secret,
1133 scope,
1134 browser,
1135 TOKEN_ENDPOINT,
1136 )
1137 .await
1138}
1139
1140pub(crate) async fn login_to(
1143 settings_path: &Path,
1144 profile: Option<&str>,
1145 client_id: &str,
1146 client_secret: &Secret,
1147 scope: DriveGrantedScopes,
1148 browser: &BrowserConfig,
1149 token_endpoint: &str,
1150) -> Result<DriveAuthStatus> {
1151 Settings::ensure_secrets_replaceable(
1154 settings_path,
1155 profile,
1156 &[DRIVE_CLIENT_SECRET, DRIVE_REFRESH_TOKEN],
1157 &SystemEnv,
1158 )?;
1159 let credentials =
1160 run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1161 save_credentials_to(settings_path, profile, &credentials)?;
1162 Ok(status_from_credentials(&credentials))
1163}
1164
1165pub(crate) async fn login_for(
1173 explicit: Option<&str>,
1174 client_id: &str,
1175 client_secret: &Secret,
1176 scope: DriveGrantedScopes,
1177 browser: &BrowserConfig,
1178) -> Result<DriveAuthStatus> {
1179 let settings = Settings::load_or_warn_default();
1180 match resolve_for_write(&settings.drive, explicit)? {
1181 ResolvedAccount::Unconfigured => {
1182 login_to(
1183 &Settings::get_settings_path()?,
1184 active_profile_from(&SystemEnv).as_deref(),
1185 client_id,
1186 client_secret,
1187 scope,
1188 browser,
1189 TOKEN_ENDPOINT,
1190 )
1191 .await
1192 }
1193 ResolvedAccount::Named(name) => {
1194 let credentials =
1195 run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1196 Settings::upsert_drive_account(
1197 &Settings::get_settings_path()?,
1198 &name,
1199 &named_account_vars(&credentials),
1200 )?;
1201 Ok(status_from_credentials(&credentials))
1202 }
1203 }
1204}
1205
1206async fn run_login_flow(
1211 client_id: &str,
1212 client_secret: &Secret,
1213 scope: DriveGrantedScopes,
1214 browser: &BrowserConfig,
1215 token_endpoint: &str,
1216) -> Result<DriveCredentials> {
1217 let (listener, port) = bind_callback_listener(browser).await?;
1218 let redirect_uri = format!("http://127.0.0.1:{port}");
1219
1220 let pending = generate_pending_login();
1221 let challenge = code_challenge(&pending.code_verifier);
1222 let auth_url =
1223 build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1224 open_browser(&browser.launch, auth_url.as_str())?;
1225
1226 let callback = wait_for_callback(listener).await?;
1227 if let Some(error) = callback.error {
1228 return Err(DriveError::authorization_denied(
1229 &error,
1230 callback.error_description.as_deref(),
1231 )
1232 .into());
1233 }
1234 let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1235 return Err(DriveError::MalformedCallback.into());
1236 };
1237 if returned_state != pending.state {
1242 return Err(DriveError::StateMismatch.into());
1243 }
1244
1245 let http = reqwest::Client::builder()
1246 .connect_timeout(crate::utils::http::connect_timeout())
1247 .read_timeout(crate::utils::http::read_timeout())
1248 .build()
1249 .context("Failed to build HTTP client")?;
1250 let tokens = exchange_code_for_tokens(
1251 &http,
1252 token_endpoint,
1253 client_id,
1254 client_secret.expose_secret(),
1255 &code,
1256 &pending.code_verifier,
1257 &redirect_uri,
1258 )
1259 .await?;
1260 let refresh_token = tokens
1261 .refresh_token
1262 .ok_or(DriveError::MalformedTokenResponse("refresh_token"))?;
1263 let granted_raw = tokens.scope.unwrap_or_default();
1264 let granted_scope = DriveGrantedScopes::from_granted(&granted_raw).ok_or_else(|| {
1265 let received = if granted_raw.trim().is_empty() {
1266 "none".to_string()
1267 } else {
1268 granted_raw
1269 .split_whitespace()
1270 .collect::<Vec<_>>()
1271 .join(", ")
1272 };
1273 DriveError::NoScopeGranted(received)
1274 })?;
1275
1276 Ok(DriveCredentials {
1277 client_id: client_id.to_string(),
1278 client_secret: client_secret.clone(),
1279 refresh_token: refresh_token.into(),
1280 scope: granted_scope,
1281 })
1282}
1283
1284fn status_from_credentials(credentials: &DriveCredentials) -> DriveAuthStatus {
1287 DriveAuthStatus {
1288 has_client_id: true,
1289 has_client_secret: true,
1290 has_refresh_token: true,
1291 scope: Some(credentials.scope.as_str()),
1292 }
1293}
1294
1295#[cfg(test)]
1296#[allow(clippy::unwrap_used, clippy::expect_used)]
1297mod tests {
1298 use std::fs;
1299 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1300 use std::sync::Arc;
1301
1302 use super::*;
1303
1304 #[test]
1309 fn from_granted_sets_each_flag_independently_order_agnostic() {
1310 let scope =
1311 DriveGrantedScopes::from_granted(&format!("{SCOPE_FULL} {SCOPE_READONLY}")).unwrap();
1312 assert!(scope.readonly);
1313 assert!(!scope.metadata);
1314 assert!(!scope.file);
1315 assert!(scope.full);
1316 }
1317
1318 #[test]
1319 fn from_granted_recognizes_all_four_scopes_together() {
1320 let granted = format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}");
1321 let scope = DriveGrantedScopes::from_granted(&granted).unwrap();
1322 assert!(scope.readonly);
1323 assert!(scope.metadata);
1324 assert!(scope.file);
1325 assert!(scope.full);
1326 }
1327
1328 #[test]
1329 fn from_granted_returns_none_when_no_drive_scope_present() {
1330 assert_eq!(
1331 DriveGrantedScopes::from_granted("openid email profile"),
1332 None
1333 );
1334 assert_eq!(DriveGrantedScopes::from_granted(""), None);
1335 }
1336
1337 #[test]
1343 fn summary_never_invents_metadata_for_a_full_scope_account() {
1344 let scope = DriveGrantedScopes {
1345 readonly: true,
1346 metadata: false,
1347 file: false,
1348 full: true,
1349 };
1350 assert_eq!(scope.summary(), "drive.readonly, drive");
1351 assert!(
1352 !scope.summary().contains("metadata"),
1353 "the account holds no metadata scope"
1354 );
1355 }
1356
1357 #[test]
1361 fn summary_matches_the_documented_write_write_file_example() {
1362 let scope = DriveGrantedScopes {
1363 readonly: true,
1364 metadata: true,
1365 file: true,
1366 full: false,
1367 };
1368 assert_eq!(
1369 scope.summary(),
1370 "drive.readonly, drive.metadata, drive.file"
1371 );
1372 }
1373
1374 #[test]
1378 fn summary_renders_exactly_the_flags_that_are_set() {
1379 for bits in 0..16u8 {
1380 let scope = DriveGrantedScopes {
1381 readonly: bits & 1 != 0,
1382 metadata: bits & 2 != 0,
1383 file: bits & 4 != 0,
1384 full: bits & 8 != 0,
1385 };
1386 let summary = scope.summary();
1387 for (set, name) in [
1388 (scope.readonly, "drive.readonly"),
1389 (scope.metadata, "drive.metadata"),
1390 (scope.file, "drive.file"),
1391 (scope.full, "drive"),
1392 ] {
1393 let present = summary.split(", ").any(|part| part == name);
1396 assert_eq!(present, set, "{name} in {summary:?} (bits {bits})");
1397 }
1398 }
1399 }
1400
1401 #[test]
1402 fn summary_of_an_empty_set_is_not_an_empty_string() {
1403 assert_eq!(DriveGrantedScopes::default().summary(), "(none)");
1404 }
1405
1406 #[test]
1409 fn summary_and_as_str_agree_on_which_scopes_are_present() {
1410 for bits in 0..16u8 {
1411 let scope = DriveGrantedScopes {
1412 readonly: bits & 1 != 0,
1413 metadata: bits & 2 != 0,
1414 file: bits & 4 != 0,
1415 full: bits & 8 != 0,
1416 };
1417 let wire_count = scope.as_str().split_whitespace().count();
1418 let short_count = if scope.summary() == "(none)" {
1419 0
1420 } else {
1421 scope.summary().split(", ").count()
1422 };
1423 assert_eq!(wire_count, short_count, "bits {bits}");
1424 }
1425 }
1426
1427 #[test]
1428 fn as_str_round_trips_through_from_granted() {
1429 let scope = DriveGrantedScopes {
1430 readonly: true,
1431 metadata: false,
1432 file: true,
1433 full: false,
1434 };
1435 let round_tripped = DriveGrantedScopes::from_granted(&scope.as_str()).unwrap();
1436 assert_eq!(scope, round_tripped);
1437 }
1438
1439 #[test]
1440 fn as_str_orders_scopes_readonly_metadata_file_full() {
1441 let scope = DriveGrantedScopes {
1442 readonly: true,
1443 metadata: true,
1444 file: true,
1445 full: true,
1446 };
1447 assert_eq!(
1448 scope.as_str(),
1449 format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}")
1450 );
1451 }
1452
1453 #[test]
1454 fn allows_predicates_reflect_the_capability_each_names() {
1455 assert!(!DriveGrantedScopes::READONLY.allows_write());
1456 assert!(!DriveGrantedScopes::READONLY.allows_rename_move());
1457 assert!(!DriveGrantedScopes::READONLY.allows_create_or_upload());
1458 assert!(!DriveGrantedScopes::READONLY.allows_edit_arbitrary_content());
1459
1460 assert!(DriveGrantedScopes::METADATA.allows_write());
1461 assert!(DriveGrantedScopes::METADATA.allows_rename_move());
1462 assert!(!DriveGrantedScopes::METADATA.allows_create_or_upload());
1463
1464 let file_only = DriveGrantedScopes {
1465 readonly: true,
1466 metadata: false,
1467 file: true,
1468 full: false,
1469 };
1470 assert!(file_only.allows_create_or_upload());
1471 assert!(
1472 !file_only.allows_edit_arbitrary_content(),
1473 "drive.file alone cannot edit an arbitrary pre-existing file"
1474 );
1475
1476 let full_only = DriveGrantedScopes {
1477 readonly: true,
1478 metadata: false,
1479 file: false,
1480 full: true,
1481 };
1482 assert!(full_only.allows_create_or_upload());
1483 assert!(full_only.allows_edit_arbitrary_content());
1484 }
1485
1486 #[test]
1487 fn code_challenge_matches_rfc_7636_test_vector() {
1488 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1490 let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1491 assert_eq!(code_challenge(verifier), expected);
1492 }
1493
1494 #[test]
1495 fn code_challenge_output_is_url_safe_no_padding() {
1496 let challenge = code_challenge("some-verifier-value");
1497 assert!(!challenge.contains('+'));
1498 assert!(!challenge.contains('/'));
1499 assert!(!challenge.contains('='));
1500 }
1501
1502 #[test]
1503 fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1504 let pending = generate_pending_login();
1505 assert_ne!(pending.state, pending.code_verifier);
1506 assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1507 assert!(pending
1508 .code_verifier
1509 .chars()
1510 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1511 }
1512
1513 #[test]
1514 fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1515 let url = build_authorization_url(
1516 "client-123",
1517 "http://127.0.0.1:5555",
1518 DriveGrantedScopes::READONLY,
1519 "state-abc",
1520 "challenge-xyz",
1521 )
1522 .unwrap();
1523 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1524 assert_eq!(query.get("client_id").unwrap(), "client-123");
1525 assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1526 assert_eq!(query.get("response_type").unwrap(), "code");
1527 assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1528 assert_eq!(query.get("state").unwrap(), "state-abc");
1529 assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1530 assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1531 assert_eq!(query.get("access_type").unwrap(), "offline");
1532 assert_eq!(query.get("prompt").unwrap(), "consent");
1533 }
1534
1535 #[test]
1536 fn build_authorization_url_uses_additive_scope_when_metadata_requested() {
1537 let url = build_authorization_url(
1538 "client-123",
1539 "http://127.0.0.1:5555",
1540 DriveGrantedScopes::METADATA,
1541 "state-abc",
1542 "challenge-xyz",
1543 )
1544 .unwrap();
1545 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1546 assert_eq!(
1547 query.get("scope").unwrap(),
1548 &format!("{SCOPE_READONLY} {SCOPE_METADATA}")
1549 );
1550 }
1551
1552 #[test]
1553 fn parse_callback_extracts_code_and_state() {
1554 let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1555 let result = parse_callback(request).unwrap();
1556 assert_eq!(result.code.as_deref(), Some("abc123"));
1557 assert_eq!(result.state.as_deref(), Some("xyz789"));
1558 assert!(result.error.is_none());
1559 }
1560
1561 #[test]
1562 fn parse_callback_extracts_error_and_error_description() {
1563 let request =
1564 "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1565 let result = parse_callback(request).unwrap();
1566 assert_eq!(result.error.as_deref(), Some("access_denied"));
1567 assert_eq!(result.error_description.as_deref(), Some("user declined"));
1568 }
1569
1570 #[test]
1571 fn parse_callback_missing_query_string_is_none() {
1572 assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1573 assert!(parse_callback("garbage").is_none());
1574 }
1575
1576 #[test]
1577 fn parse_callback_ignores_unrecognized_query_keys() {
1578 let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1579 let result = parse_callback(request).unwrap();
1580 assert_eq!(result.code.as_deref(), Some("abc"));
1581 assert_eq!(result.state.as_deref(), Some("xyz"));
1582 }
1583
1584 #[test]
1585 fn open_browser_manual_logs_and_succeeds() {
1586 assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1587 }
1588
1589 #[test]
1590 fn open_browser_command_substitutes_url_placeholder() {
1591 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1592 assert!(open_browser(&launch, "https://example/auth").is_ok());
1593 }
1594
1595 #[test]
1596 fn open_browser_command_appends_url_when_no_placeholder() {
1597 let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1598 assert!(open_browser(&launch, "https://example/auth").is_ok());
1599 }
1600
1601 #[test]
1602 fn open_browser_command_passes_through_args_without_the_placeholder() {
1603 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1607 assert!(open_browser(&launch, "https://example/auth").is_ok());
1608 }
1609
1610 #[test]
1611 fn open_browser_command_rejects_empty_args() {
1612 let launch = BrowserLaunch::Command(vec![]);
1613 let err = open_browser(&launch, "u").unwrap_err();
1614 assert!(err.to_string().contains("empty browser command"));
1615 }
1616
1617 #[test]
1620 fn named_account_vars_maps_credentials_to_json_string_values() {
1621 let credentials = DriveCredentials {
1622 client_id: "client-1".to_string(),
1623 client_secret: Secret::new("secret-1"),
1624 refresh_token: Secret::new("refresh-1"),
1625 scope: DriveGrantedScopes::READONLY,
1626 };
1627 assert_eq!(
1628 named_account_vars(&credentials),
1629 [
1630 (
1631 "client_id",
1632 serde_json::Value::String("client-1".to_string())
1633 ),
1634 (
1635 "client_secret",
1636 serde_json::Value::String("secret-1".to_string())
1637 ),
1638 (
1639 "refresh_token",
1640 serde_json::Value::String("refresh-1".to_string())
1641 ),
1642 (
1643 "scope",
1644 serde_json::Value::String(SCOPE_READONLY.to_string())
1645 ),
1646 ]
1647 );
1648 }
1649
1650 fn assert_is_auto(config: BrowserConfig) {
1653 assert!(matches!(config.launch, BrowserLaunch::Auto));
1654 }
1655
1656 #[test]
1657 fn build_browser_config_defaults_to_auto_with_no_account() {
1658 assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1659 }
1660
1661 #[test]
1662 fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1663 let account = DriveAccountSettings {
1664 email_address: Some("alice@example.com".to_string()),
1665 ..DriveAccountSettings::default()
1666 };
1667 assert_is_auto(
1670 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1671 );
1672 }
1673
1674 #[test]
1675 fn build_browser_config_uses_browser_command_verbatim() {
1676 let account = DriveAccountSettings {
1677 browser_command: Some("chrome --new-window {url}".to_string()),
1678 ..DriveAccountSettings::default()
1679 };
1680 let config =
1681 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1682 assert!(matches!(
1683 config.launch,
1684 BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1685 ));
1686 }
1687
1688 #[test]
1689 fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1690 let account = DriveAccountSettings {
1691 browser_command: Some("chrome {url}".to_string()),
1692 chrome_profile_from_email: true,
1693 email_address: Some("alice@example.com".to_string()),
1694 ..DriveAccountSettings::default()
1695 };
1696 let config =
1697 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1698 assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1699 }
1700
1701 #[test]
1702 fn build_browser_config_rejects_a_malformed_browser_command() {
1703 let account = DriveAccountSettings {
1704 browser_command: Some("chrome \"--flag".to_string()),
1705 ..DriveAccountSettings::default()
1706 };
1707 let err =
1708 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1709 assert!(err.to_string().contains("browser_command"));
1710 }
1711
1712 #[test]
1713 fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1714 let account = DriveAccountSettings {
1715 chrome_profile_from_email: true,
1716 email_address: Some("alice@example.com".to_string()),
1717 ..DriveAccountSettings::default()
1718 };
1719 let config = build_browser_config(Some(&account), |email| {
1720 assert_eq!(email, "alice@example.com");
1721 Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1722 })
1723 .unwrap();
1724 assert!(matches!(
1725 config.launch,
1726 BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1727 ));
1728 }
1729
1730 #[test]
1731 fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1732 let account = DriveAccountSettings {
1733 chrome_profile_from_email: true,
1734 email_address: Some("alice@example.com".to_string()),
1735 ..DriveAccountSettings::default()
1736 };
1737 assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1738 }
1739
1740 #[test]
1741 fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1742 let account = DriveAccountSettings {
1743 chrome_profile_from_email: true,
1744 ..DriveAccountSettings::default()
1745 };
1746 assert_is_auto(
1747 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1748 );
1749 }
1750
1751 #[test]
1754 fn resolve_browser_config_for_unconfigured_account_defaults_to_auto() {
1755 let guard = crate::drive::test_support::EnvGuard::take();
1756 let _dir = guard.clear_credentials();
1757 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
1758 std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
1759 std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
1760
1761 let drive = DriveSettings::default();
1762 assert_is_auto(resolve_browser_config_for(&drive, None).unwrap());
1763 }
1764
1765 #[test]
1766 fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1767 let guard = crate::drive::test_support::EnvGuard::take();
1768 let _dir = guard.clear_credentials();
1769
1770 let mut drive = DriveSettings::default();
1771 drive.accounts.insert(
1772 "work".to_string(),
1773 DriveAccountSettings {
1774 email_address: Some("alice@example.com".to_string()),
1775 ..DriveAccountSettings::default()
1776 },
1777 );
1778
1779 assert_is_auto(resolve_browser_config_for(&drive, Some("work")).unwrap());
1782 }
1783
1784 #[tokio::test]
1787 async fn wait_for_callback_times_out_when_nothing_connects() {
1788 let browser = BrowserConfig::default();
1789 let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1790 let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1791 .await
1792 .unwrap_err();
1793 assert!(matches!(
1794 err.downcast_ref::<DriveError>(),
1795 Some(DriveError::CallbackTimeout(_))
1796 ));
1797 }
1798
1799 #[tokio::test]
1800 async fn wait_for_callback_reads_a_real_connection() {
1801 let browser = BrowserConfig::default();
1802 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1803
1804 let client = tokio::spawn(async move {
1805 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1806 .await
1807 .unwrap();
1808 stream
1809 .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1810 .await
1811 .unwrap();
1812 });
1813
1814 let result = wait_for_callback(listener).await.unwrap();
1815 client.await.unwrap();
1816 assert_eq!(result.code.as_deref(), Some("abc"));
1817 assert_eq!(result.state.as_deref(), Some("xyz"));
1818 }
1819
1820 #[tokio::test]
1821 async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1822 let browser = BrowserConfig::default();
1823 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1824
1825 let client = tokio::spawn(async move {
1826 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1827 .await
1828 .unwrap();
1829 stream.write_all(b"not an http request").await.unwrap();
1830 });
1831
1832 let err = wait_for_callback(listener).await.unwrap_err();
1833 client.await.unwrap();
1834 assert!(matches!(
1835 err.downcast_ref::<DriveError>(),
1836 Some(DriveError::MalformedCallback)
1837 ));
1838 }
1839
1840 #[tokio::test]
1843 async fn exchange_code_for_tokens_posts_expected_form_body() {
1844 let server = wiremock::MockServer::start().await;
1845 wiremock::Mock::given(wiremock::matchers::method("POST"))
1846 .and(wiremock::matchers::path("/token"))
1847 .and(wiremock::matchers::body_string_contains(
1848 "grant_type=authorization_code",
1849 ))
1850 .and(wiremock::matchers::body_string_contains(
1851 "code_verifier=verifier-1",
1852 ))
1853 .and(wiremock::matchers::body_string_contains(
1854 "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1855 ))
1856 .respond_with(
1857 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1858 "access_token": "at-1",
1859 "refresh_token": "rt-1",
1860 "expires_in": 3600,
1861 "scope": SCOPE_READONLY,
1862 })),
1863 )
1864 .expect(1)
1865 .mount(&server)
1866 .await;
1867
1868 let http = reqwest::Client::new();
1869 let token_endpoint = format!("{}/token", server.uri());
1870 let response = exchange_code_for_tokens(
1871 &http,
1872 &token_endpoint,
1873 "client-1",
1874 "secret-1",
1875 "code-1",
1876 "verifier-1",
1877 "http://127.0.0.1:9999",
1878 )
1879 .await
1880 .unwrap();
1881 assert_eq!(response.access_token, "at-1");
1882 assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1883 }
1884
1885 #[tokio::test]
1886 async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1887 let server = wiremock::MockServer::start().await;
1888 wiremock::Mock::given(wiremock::matchers::method("POST"))
1889 .respond_with(
1890 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1891 "error": "invalid_grant",
1892 "error_description": "Bad Request",
1893 })),
1894 )
1895 .mount(&server)
1896 .await;
1897
1898 let http = reqwest::Client::new();
1899 let err = exchange_code_for_tokens(
1900 &http,
1901 &server.uri(),
1902 "c",
1903 "s",
1904 "code",
1905 "verifier",
1906 "http://127.0.0.1:1",
1907 )
1908 .await
1909 .unwrap_err();
1910 assert!(err.to_string().contains("PKCE"));
1911 }
1912
1913 async fn connect_and_send(port: u16, request_line: &[u8]) {
1933 let mut stream = loop {
1934 match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1935 Ok(stream) => break stream,
1936 Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1937 }
1938 };
1939 stream.write_all(request_line).await.unwrap();
1940 }
1941
1942 fn reserve_free_port() -> u16 {
1943 std::net::TcpListener::bind("127.0.0.1:0")
1944 .unwrap()
1945 .local_addr()
1946 .unwrap()
1947 .port()
1948 }
1949
1950 fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1954 err.to_string()
1955 .contains("Failed to start the local OAuth callback listener")
1956 }
1957
1958 const PORT_RETRY_ATTEMPTS: u32 = 5;
1959
1960 async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<DriveAuthStatus>
1965 where
1966 F: FnMut(u16) -> Fut,
1967 Fut: std::future::Future<Output = Result<DriveAuthStatus>>,
1968 {
1969 for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1970 let result = attempt(reserve_free_port()).await;
1971 let is_retryable_conflict =
1972 matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1973 if !is_retryable_conflict {
1974 return result;
1975 }
1976 }
1977 unreachable!("loop always returns on its last iteration")
1978 }
1979
1980 async fn finish_connector(
1985 connector: tokio::task::JoinHandle<()>,
1986 result: &Result<DriveAuthStatus>,
1987 ) {
1988 match result {
1989 Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1990 _ => connector.await.unwrap(),
1991 }
1992 }
1993
1994 async fn wait_for_captured_url(path: &Path) -> String {
1998 loop {
1999 if let Ok(contents) = std::fs::read_to_string(path) {
2000 if !contents.is_empty() {
2001 return contents;
2002 }
2003 }
2004 tokio::time::sleep(Duration::from_millis(2)).await;
2005 }
2006 }
2007
2008 #[tokio::test]
2009 async fn login_to_refuses_before_the_browser_flow_when_the_token_is_command_fetched() {
2010 let temp_dir = tempfile::TempDir::new().unwrap();
2011 let settings_path = temp_dir.path().join("settings.json");
2012 std::fs::write(
2013 &settings_path,
2014 r#"{"env": {"DRIVE_REFRESH_TOKEN_COMMAND": "op read op://v/secret"}}"#,
2015 )
2016 .unwrap();
2017 let browser = BrowserConfig {
2018 launch: BrowserLaunch::Manual,
2019 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2020 callback_port: 0,
2021 };
2022 let result = tokio::time::timeout(
2025 Duration::from_secs(5),
2026 login_to(
2027 &settings_path,
2028 None,
2029 "client-id",
2030 &Secret::new("client-secret"),
2031 DriveGrantedScopes::READONLY,
2032 &browser,
2033 "http://127.0.0.1:1/token",
2034 ),
2035 )
2036 .await
2037 .expect("login must refuse before starting the browser flow");
2038 let err = result.unwrap_err().to_string();
2039 assert!(err.contains("DRIVE_REFRESH_TOKEN_COMMAND"), "{err}");
2040 assert!(!err.contains("op read"), "{err}");
2041 }
2042
2043 async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
2050 std::fs::create_dir_all("tmp").ok();
2051 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2052 let settings_path = temp_dir.path().join("settings.json");
2053
2054 let result = run_with_port_retry(|port| {
2055 let settings_path = settings_path.clone();
2056 async move {
2057 let browser = BrowserConfig {
2058 launch: BrowserLaunch::Manual,
2059 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2060 callback_port: port,
2061 };
2062 let connector = tokio::spawn(connect_and_send(port, request_line));
2063
2064 let result = login_to(
2065 &settings_path,
2066 None,
2067 "client-id",
2068 &Secret::new("client-secret"),
2069 DriveGrantedScopes::READONLY,
2070 &browser,
2071 "http://127.0.0.1:1/token", )
2073 .await;
2074
2075 finish_connector(connector, &result).await;
2076 result
2077 }
2078 })
2079 .await;
2080
2081 let err = result.unwrap_err();
2082 assert!(!settings_path.exists());
2083 err
2084 }
2085
2086 #[tokio::test]
2087 async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
2088 let attempts = AtomicU32::new(0);
2089
2090 let status = run_with_port_retry(|_port| {
2091 let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
2092 async move {
2093 if attempt_no == 0 {
2094 Err(anyhow::anyhow!(
2095 "Failed to start the local OAuth callback listener: address in use"
2096 ))
2097 } else {
2098 Ok(DriveAuthStatus {
2099 has_client_id: true,
2100 has_client_secret: true,
2101 has_refresh_token: true,
2102 scope: None,
2103 })
2104 }
2105 }
2106 })
2107 .await
2108 .unwrap();
2109
2110 assert_eq!(attempts.load(Ordering::SeqCst), 2);
2111 assert!(status.has_client_id);
2112 }
2113
2114 #[tokio::test]
2115 async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
2116 let attempts = AtomicU32::new(0);
2117
2118 let result = run_with_port_retry(|_port| {
2119 attempts.fetch_add(1, Ordering::SeqCst);
2120 async move { Err(anyhow::anyhow!("some other failure")) }
2121 })
2122 .await;
2123
2124 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2125 assert!(result.is_err());
2126 }
2127
2128 #[tokio::test]
2129 async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
2130 let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
2131 let connector = tokio::spawn(async move {
2132 tokio::time::sleep(Duration::from_secs(3600)).await;
2133 let _ = tx.send(());
2134 });
2135 let result: Result<DriveAuthStatus> = Err(anyhow::anyhow!(
2136 "Failed to start the local OAuth callback listener: address in use"
2137 ));
2138
2139 tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
2140 .await
2141 .expect("finish_connector must not wait for an aborted connector");
2142
2143 assert!(
2144 rx.try_recv().is_err(),
2145 "connector must have been aborted, not run to completion"
2146 );
2147 }
2148
2149 #[tokio::test]
2150 async fn finish_connector_awaits_connector_when_login_to_succeeds() {
2151 let ran = Arc::new(AtomicBool::new(false));
2152 let ran_clone = ran.clone();
2153 let connector = tokio::spawn(async move {
2154 ran_clone.store(true, Ordering::SeqCst);
2155 });
2156 let result = Ok(DriveAuthStatus {
2157 has_client_id: true,
2158 has_client_secret: true,
2159 has_refresh_token: true,
2160 scope: None,
2161 });
2162
2163 finish_connector(connector, &result).await;
2164
2165 assert!(ran.load(Ordering::SeqCst));
2166 }
2167
2168 #[tokio::test]
2169 async fn wait_for_captured_url_polls_until_content_is_written() {
2170 std::fs::create_dir_all("tmp").ok();
2171 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2172 let path = temp_dir.path().join("captured-url.txt");
2173
2174 let write_path = path.clone();
2175 tokio::spawn(async move {
2176 tokio::time::sleep(Duration::from_millis(10)).await;
2177 std::fs::write(&write_path, "").unwrap();
2178 tokio::time::sleep(Duration::from_millis(10)).await;
2179 std::fs::write(&write_path, "https://example.com/authorize").unwrap();
2180 });
2181
2182 let contents = wait_for_captured_url(&path).await;
2183 assert_eq!(contents, "https://example.com/authorize");
2184 }
2185
2186 #[tokio::test]
2187 async fn login_to_rejects_a_callback_with_mismatched_state() {
2188 let err =
2189 run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
2190
2191 assert!(matches!(
2192 err.downcast_ref::<DriveError>(),
2193 Some(DriveError::StateMismatch)
2194 ));
2195 }
2196
2197 #[tokio::test]
2198 async fn login_to_surfaces_access_denied_from_the_callback() {
2199 let err = run_login_to_expect_err(
2200 b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
2201 )
2202 .await;
2203
2204 match err.downcast_ref::<DriveError>() {
2205 Some(DriveError::AuthorizationDenied(message)) => {
2206 assert!(message.contains("access_denied"));
2207 assert!(message.contains("user declined"));
2208 }
2209 other => panic!("expected AuthorizationDenied, got {other:?}"),
2210 }
2211 }
2212
2213 #[tokio::test]
2214 async fn login_to_rejects_a_callback_missing_code_and_state() {
2215 let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
2216
2217 assert!(matches!(
2218 err.downcast_ref::<DriveError>(),
2219 Some(DriveError::MalformedCallback)
2220 ));
2221 }
2222
2223 #[tokio::test]
2224 async fn login_to_completes_full_success_flow_and_persists_credentials() {
2225 std::fs::create_dir_all("tmp").ok();
2234 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2235 let capture_path = temp_dir.path().join("captured-url.txt");
2236 let settings_path = temp_dir.path().join("settings.json");
2237
2238 let server = wiremock::MockServer::start().await;
2239 wiremock::Mock::given(wiremock::matchers::method("POST"))
2240 .and(wiremock::matchers::path("/token"))
2241 .respond_with(
2242 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2243 "access_token": "at-1",
2244 "refresh_token": "rt-1",
2245 "expires_in": 3600,
2246 "scope": SCOPE_READONLY,
2247 })),
2248 )
2249 .expect(1)
2250 .mount(&server)
2251 .await;
2252
2253 let status = run_with_port_retry(|port| {
2254 let capture_path = capture_path.clone();
2255 let settings_path = settings_path.clone();
2256 let token_endpoint = format!("{}/token", server.uri());
2257 async move {
2258 let browser = BrowserConfig {
2259 launch: BrowserLaunch::Command(vec![
2260 "/bin/sh".to_string(),
2261 "-c".to_string(),
2262 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2263 "{url}".to_string(),
2264 ]),
2265 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2266 callback_port: port,
2267 };
2268
2269 let connector = tokio::spawn(async move {
2270 let auth_url = wait_for_captured_url(&capture_path).await;
2271 let parsed = Url::parse(&auth_url).unwrap();
2272 let state = parsed
2273 .query_pairs()
2274 .find(|(k, _)| k == "state")
2275 .map(|(_, v)| v.into_owned())
2276 .expect("authorization URL must carry a state param");
2277 connect_and_send(
2278 port,
2279 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2280 )
2281 .await;
2282 });
2283
2284 let result = login_to(
2285 &settings_path,
2286 None,
2287 "client-id",
2288 &Secret::new("client-secret"),
2289 DriveGrantedScopes::READONLY,
2290 &browser,
2291 &token_endpoint,
2292 )
2293 .await;
2294
2295 finish_connector(connector, &result).await;
2296 result
2297 }
2298 })
2299 .await
2300 .unwrap();
2301
2302 assert!(status.has_client_id);
2303 assert!(status.has_client_secret);
2304 assert!(status.has_refresh_token);
2305 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2306
2307 let saved = std::fs::read_to_string(&settings_path).unwrap();
2308 assert!(saved.contains("rt-1"));
2309 assert!(saved.contains("client-id"));
2310 }
2311
2312 async fn run_login_to_with_token_response(
2319 token_response_body: serde_json::Value,
2320 ) -> (Result<DriveAuthStatus>, std::path::PathBuf) {
2321 std::fs::create_dir_all("tmp").ok();
2322 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2323 let capture_path = temp_dir.path().join("captured-url.txt");
2324 let settings_path = temp_dir.path().join("settings.json");
2325
2326 let server = wiremock::MockServer::start().await;
2327 wiremock::Mock::given(wiremock::matchers::method("POST"))
2328 .and(wiremock::matchers::path("/token"))
2329 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
2330 .expect(1)
2331 .mount(&server)
2332 .await;
2333
2334 let result = run_with_port_retry(|port| {
2335 let capture_path = capture_path.clone();
2336 let settings_path = settings_path.clone();
2337 let token_endpoint = format!("{}/token", server.uri());
2338 async move {
2339 let browser = BrowserConfig {
2340 launch: BrowserLaunch::Command(vec![
2341 "/bin/sh".to_string(),
2342 "-c".to_string(),
2343 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2344 "{url}".to_string(),
2345 ]),
2346 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2347 callback_port: port,
2348 };
2349
2350 let connector = tokio::spawn(async move {
2351 let auth_url = wait_for_captured_url(&capture_path).await;
2352 let parsed = Url::parse(&auth_url).unwrap();
2353 let state = parsed
2354 .query_pairs()
2355 .find(|(k, _)| k == "state")
2356 .map(|(_, v)| v.into_owned())
2357 .expect("authorization URL must carry a state param");
2358 connect_and_send(
2359 port,
2360 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2361 )
2362 .await;
2363 });
2364
2365 let result = login_to(
2366 &settings_path,
2367 None,
2368 "client-id",
2369 &Secret::new("client-secret"),
2370 DriveGrantedScopes::READONLY,
2371 &browser,
2372 &token_endpoint,
2373 )
2374 .await;
2375
2376 finish_connector(connector, &result).await;
2377 result
2378 }
2379 })
2380 .await;
2381
2382 (result, settings_path)
2383 }
2384
2385 #[tokio::test]
2386 async fn login_to_rejects_a_grant_with_no_drive_scope() {
2387 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2388 "access_token": "at-1",
2389 "refresh_token": "rt-1",
2390 "expires_in": 3600,
2391 "scope": "openid email profile",
2392 }))
2393 .await;
2394
2395 let err = result.unwrap_err();
2396 assert!(matches!(
2397 err.downcast_ref::<DriveError>(),
2398 Some(DriveError::NoScopeGranted(received)) if received == "openid, email, profile"
2399 ));
2400 assert!(!settings_path.exists());
2401 }
2402
2403 #[tokio::test]
2404 async fn login_to_rejects_a_grant_with_missing_scope_field() {
2405 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2406 "access_token": "at-1",
2407 "refresh_token": "rt-1",
2408 "expires_in": 3600,
2409 }))
2410 .await;
2411
2412 let err = result.unwrap_err();
2413 assert!(matches!(
2414 err.downcast_ref::<DriveError>(),
2415 Some(DriveError::NoScopeGranted(received)) if received == "none"
2416 ));
2417 assert!(!settings_path.exists());
2418 }
2419
2420 #[tokio::test]
2433 async fn login_for_unconfigured_account_rejects_a_callback_with_mismatched_state() {
2434 let guard = crate::drive::test_support::EnvGuard::take();
2435 let dir = guard.clear_credentials();
2436 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2437
2438 let result = run_with_port_retry(|port| async move {
2439 let browser = BrowserConfig {
2440 launch: BrowserLaunch::Manual,
2441 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2442 callback_port: port,
2443 };
2444 let connector = tokio::spawn(connect_and_send(
2445 port,
2446 b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2447 ));
2448
2449 let result = login_for(
2450 None,
2451 "client-id",
2452 &Secret::new("client-secret"),
2453 DriveGrantedScopes::READONLY,
2454 &browser,
2455 )
2456 .await;
2457
2458 finish_connector(connector, &result).await;
2459 result
2460 })
2461 .await;
2462
2463 let err = result.unwrap_err();
2464 assert!(matches!(
2465 err.downcast_ref::<DriveError>(),
2466 Some(DriveError::StateMismatch)
2467 ));
2468 assert!(!settings_path.exists());
2469 }
2470
2471 #[tokio::test]
2472 async fn login_for_named_account_rejects_a_callback_with_mismatched_state() {
2473 let guard = crate::drive::test_support::EnvGuard::take();
2474 let dir = guard.clear_credentials();
2475 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2476
2477 let result = run_with_port_retry(|port| async move {
2478 let browser = BrowserConfig {
2479 launch: BrowserLaunch::Manual,
2480 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2481 callback_port: port,
2482 };
2483 let connector = tokio::spawn(connect_and_send(
2484 port,
2485 b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2486 ));
2487
2488 let result = login_for(
2489 Some("work"),
2490 "client-id",
2491 &Secret::new("client-secret"),
2492 DriveGrantedScopes::READONLY,
2493 &browser,
2494 )
2495 .await;
2496
2497 finish_connector(connector, &result).await;
2498 result
2499 })
2500 .await;
2501
2502 let err = result.unwrap_err();
2503 assert!(matches!(
2504 err.downcast_ref::<DriveError>(),
2505 Some(DriveError::StateMismatch)
2506 ));
2507 assert!(!settings_path.exists());
2508 }
2509
2510 #[tokio::test]
2511 async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2512 let server = wiremock::MockServer::start().await;
2513 wiremock::Mock::given(wiremock::matchers::method("POST"))
2514 .and(wiremock::matchers::body_string_contains(
2515 "grant_type=refresh_token",
2516 ))
2517 .respond_with(
2518 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2519 "access_token": "at-2",
2520 "expires_in": 1800,
2521 })),
2522 )
2523 .expect(1)
2524 .mount(&server)
2525 .await;
2526
2527 let http = reqwest::Client::new();
2528 let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2529 .await
2530 .unwrap();
2531 assert_eq!(response.access_token, "at-2");
2532 assert_eq!(response.expires_in, 1800);
2533 }
2534
2535 #[tokio::test]
2536 async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2537 let server = wiremock::MockServer::start().await;
2538 wiremock::Mock::given(wiremock::matchers::method("POST"))
2539 .respond_with(
2540 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2541 "error": "invalid_grant",
2542 })),
2543 )
2544 .mount(&server)
2545 .await;
2546
2547 let http = reqwest::Client::new();
2548 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2549 .await
2550 .unwrap_err();
2551 let msg = err.to_string();
2552 assert!(msg.contains("7 days"));
2553 assert!(msg.contains("Testing"));
2554 }
2555
2556 #[tokio::test]
2557 async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2558 let server = wiremock::MockServer::start().await;
2559 wiremock::Mock::given(wiremock::matchers::method("POST"))
2560 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2561 .mount(&server)
2562 .await;
2563
2564 let http = reqwest::Client::new();
2565 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2566 .await
2567 .unwrap_err();
2568 let msg = err.to_string();
2569 assert!(msg.contains("unparsable error body"));
2570 assert!(msg.contains("not json"));
2571 }
2572
2573 #[tokio::test]
2574 async fn token_request_propagates_network_errors() {
2575 let http = reqwest::Client::new();
2576 let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2577 .await
2578 .unwrap_err();
2579 assert!(err.to_string().contains("Failed to send token request"));
2580 }
2581
2582 #[tokio::test]
2583 async fn token_request_errors_on_unparsable_response_body() {
2584 let server = wiremock::MockServer::start().await;
2585 wiremock::Mock::given(wiremock::matchers::method("POST"))
2586 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2587 .mount(&server)
2588 .await;
2589
2590 let http = reqwest::Client::new();
2591 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2592 .await
2593 .unwrap_err();
2594 assert!(err.to_string().contains("Failed to parse"));
2595 }
2596
2597 fn test_credentials() -> DriveCredentials {
2600 DriveCredentials {
2601 client_id: "client-1".to_string(),
2602 client_secret: "secret-1".into(),
2603 refresh_token: "refresh-1".into(),
2604 scope: DriveGrantedScopes::READONLY,
2605 }
2606 }
2607
2608 #[tokio::test]
2609 async fn access_token_refreshes_on_first_call() {
2610 let server = wiremock::MockServer::start().await;
2611 wiremock::Mock::given(wiremock::matchers::method("POST"))
2612 .respond_with(
2613 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2614 "access_token": "at-first",
2615 "expires_in": 3600,
2616 })),
2617 )
2618 .expect(1)
2619 .mount(&server)
2620 .await;
2621
2622 let session = DriveSession::new_with_token_endpoint(
2623 reqwest::Client::new(),
2624 &test_credentials(),
2625 &server.uri(),
2626 );
2627 let token = session.access_token().await.unwrap();
2628 assert_eq!(token.expose_secret(), "at-first");
2629 }
2630
2631 #[tokio::test]
2632 async fn access_token_reuses_cached_token_within_skew_window() {
2633 let server = wiremock::MockServer::start().await;
2634 wiremock::Mock::given(wiremock::matchers::method("POST"))
2635 .respond_with(
2636 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2637 "access_token": "at-cached",
2638 "expires_in": 3600,
2639 })),
2640 )
2641 .expect(1)
2642 .mount(&server)
2643 .await;
2644
2645 let session = DriveSession::new_with_token_endpoint(
2646 reqwest::Client::new(),
2647 &test_credentials(),
2648 &server.uri(),
2649 );
2650 let first = session.access_token().await.unwrap();
2651 let second = session.access_token().await.unwrap();
2652 assert_eq!(first, second);
2653 }
2654
2655 #[tokio::test]
2656 async fn access_token_proactively_refreshes_when_within_skew_window() {
2657 let server = wiremock::MockServer::start().await;
2658 wiremock::Mock::given(wiremock::matchers::method("POST"))
2659 .respond_with(
2660 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2661 "access_token": "at-short",
2662 "expires_in": 30,
2664 })),
2665 )
2666 .expect(2)
2667 .mount(&server)
2668 .await;
2669
2670 let session = DriveSession::new_with_token_endpoint(
2671 reqwest::Client::new(),
2672 &test_credentials(),
2673 &server.uri(),
2674 );
2675 session.access_token().await.unwrap();
2676 session.access_token().await.unwrap();
2677 }
2678
2679 #[tokio::test]
2680 async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2681 let server = wiremock::MockServer::start().await;
2682 wiremock::Mock::given(wiremock::matchers::method("POST"))
2683 .respond_with(
2684 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2685 "access_token": "at-overflow",
2686 "expires_in": i64::MAX,
2689 })),
2690 )
2691 .expect(1)
2692 .mount(&server)
2693 .await;
2694
2695 let session = DriveSession::new_with_token_endpoint(
2696 reqwest::Client::new(),
2697 &test_credentials(),
2698 &server.uri(),
2699 );
2700 let token = session.access_token().await.unwrap();
2701 assert_eq!(token.expose_secret(), "at-overflow");
2702 }
2703
2704 #[tokio::test]
2705 async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2706 let server = wiremock::MockServer::start().await;
2707 wiremock::Mock::given(wiremock::matchers::method("POST"))
2708 .respond_with(
2709 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2710 "access_token": "at-negative",
2711 "expires_in": -3600,
2712 })),
2713 )
2714 .expect(2)
2717 .mount(&server)
2718 .await;
2719
2720 let session = DriveSession::new_with_token_endpoint(
2721 reqwest::Client::new(),
2722 &test_credentials(),
2723 &server.uri(),
2724 );
2725 session.access_token().await.unwrap();
2726 session.access_token().await.unwrap();
2727 }
2728
2729 #[tokio::test]
2730 async fn force_refresh_concurrent_callers_do_not_stampede() {
2731 let server = wiremock::MockServer::start().await;
2732 wiremock::Mock::given(wiremock::matchers::method("POST"))
2733 .respond_with(
2734 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2735 "access_token": "at-bootstrap",
2736 "expires_in": 3600,
2737 })),
2738 )
2739 .up_to_n_times(1)
2740 .with_priority(1)
2741 .mount(&server)
2742 .await;
2743 wiremock::Mock::given(wiremock::matchers::method("POST"))
2744 .respond_with(
2745 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2746 "access_token": "at-refreshed",
2747 "expires_in": 3600,
2748 })),
2749 )
2750 .expect(1)
2751 .with_priority(2)
2752 .mount(&server)
2753 .await;
2754
2755 let session = DriveSession::new_with_token_endpoint(
2756 reqwest::Client::new(),
2757 &test_credentials(),
2758 &server.uri(),
2759 );
2760 let bootstrapped = session.access_token().await.unwrap();
2761 assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2762
2763 let (a, b) = tokio::join!(
2764 session.force_refresh(&bootstrapped),
2765 session.force_refresh(&bootstrapped)
2766 );
2767 let a = a.unwrap();
2768 let b = b.unwrap();
2769 assert_eq!(a, b);
2770 assert_eq!(a.expose_secret(), "at-refreshed");
2771 }
2772
2773 #[tokio::test]
2774 async fn force_refresh_skips_network_call_when_token_already_rotated() {
2775 let server = wiremock::MockServer::start().await;
2776 wiremock::Mock::given(wiremock::matchers::method("POST"))
2777 .respond_with(
2778 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2779 "access_token": "at-a",
2780 "expires_in": 3600,
2781 })),
2782 )
2783 .up_to_n_times(1)
2784 .with_priority(1)
2785 .mount(&server)
2786 .await;
2787 wiremock::Mock::given(wiremock::matchers::method("POST"))
2788 .respond_with(
2789 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2790 "access_token": "at-b",
2791 "expires_in": 3600,
2792 })),
2793 )
2794 .expect(1)
2795 .with_priority(2)
2796 .mount(&server)
2797 .await;
2798
2799 let session = DriveSession::new_with_token_endpoint(
2800 reqwest::Client::new(),
2801 &test_credentials(),
2802 &server.uri(),
2803 );
2804 let stale = Secret::new("at-never-issued");
2805 let bootstrapped = session.access_token().await.unwrap();
2806 assert_eq!(bootstrapped.expose_secret(), "at-a");
2807
2808 let result = session.force_refresh(&stale).await.unwrap();
2811 assert_eq!(result, bootstrapped);
2812
2813 let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2815 assert_eq!(refreshed.expose_secret(), "at-b");
2816 }
2817
2818 #[test]
2821 fn drive_credentials_debug_redacts_client_secret_and_refresh_token() {
2822 let creds = DriveCredentials {
2823 client_id: "client-visible".to_string(),
2824 client_secret: "sekret-client-secret".into(),
2825 refresh_token: "sekret-refresh-token".into(),
2826 scope: DriveGrantedScopes::READONLY,
2827 };
2828 let debug = format!("{creds:?}");
2829 assert!(debug.contains("DriveCredentials"));
2830 assert!(debug.contains("client-visible"));
2831 assert!(!debug.contains("sekret-client-secret"));
2832 assert!(!debug.contains("sekret-refresh-token"));
2833 assert!(debug.contains("client_secret: <redacted>"));
2834 assert!(debug.contains("refresh_token: <redacted>"));
2835 }
2836
2837 #[test]
2838 fn drive_auth_status_yaml_serialization_contains_no_secret_values() {
2839 let env = crate::test_support::env::MapEnv::new()
2840 .with(DRIVE_CLIENT_ID, "client-id-value")
2841 .with(DRIVE_CLIENT_SECRET, "sekret-do-not-leak")
2842 .with(DRIVE_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2843 .with(DRIVE_SCOPE, SCOPE_READONLY);
2844 let status = status_with(&env);
2845 let yaml = serde_yaml::to_string(&status).unwrap();
2846 assert!(!yaml.contains("sekret-do-not-leak"));
2847 assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2848 }
2849
2850 use crate::test_support::env::MapEnv;
2853
2854 #[test]
2855 fn refresh_token_can_come_from_a_file() {
2856 let (_dir, path) = crate::test_support::env::secret_file("rt-from-file\n");
2857 let env = MapEnv::new()
2858 .with(DRIVE_CLIENT_ID, "id")
2859 .with(DRIVE_CLIENT_SECRET, "secret")
2860 .with("DRIVE_REFRESH_TOKEN_FILE", &path);
2861 let creds = load_credentials_with(&env).unwrap();
2862 assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
2863 assert!(status_with(&env).has_refresh_token);
2864 }
2865
2866 #[test]
2867 fn status_reports_all_false_when_nothing_configured() {
2868 let status = status_with(&MapEnv::new());
2869 assert!(!status.has_client_id);
2870 assert!(!status.has_client_secret);
2871 assert!(!status.has_refresh_token);
2872 assert_eq!(status.scope, None);
2873 }
2874
2875 #[test]
2876 fn status_reports_scope_when_present() {
2877 let env = MapEnv::new().with(DRIVE_SCOPE, SCOPE_READONLY);
2878 let status = status_with(&env);
2879 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2880 }
2881
2882 #[test]
2883 fn load_credentials_errors_when_client_id_missing() {
2884 let env = MapEnv::new()
2885 .with(DRIVE_CLIENT_SECRET, "s")
2886 .with(DRIVE_REFRESH_TOKEN, "r");
2887 let err = load_credentials_with(&env).unwrap_err();
2888 assert!(err.to_string().contains("not configured"));
2889 }
2890
2891 #[test]
2892 fn load_credentials_errors_when_client_secret_missing() {
2893 let env = MapEnv::new()
2894 .with(DRIVE_CLIENT_ID, "c")
2895 .with(DRIVE_REFRESH_TOKEN, "r");
2896 assert!(load_credentials_with(&env).is_err());
2897 }
2898
2899 #[test]
2900 fn load_credentials_errors_when_refresh_token_missing() {
2901 let env = MapEnv::new()
2902 .with(DRIVE_CLIENT_ID, "c")
2903 .with(DRIVE_CLIENT_SECRET, "s");
2904 assert!(load_credentials_with(&env).is_err());
2905 }
2906
2907 #[test]
2908 fn load_credentials_succeeds_with_all_three_present() {
2909 let env = MapEnv::new()
2910 .with(DRIVE_CLIENT_ID, "c")
2911 .with(DRIVE_CLIENT_SECRET, "s")
2912 .with(DRIVE_REFRESH_TOKEN, "r");
2913 let creds = load_credentials_with(&env).unwrap();
2914 assert_eq!(creds.client_id, "c");
2915 assert_eq!(creds.scope, DriveGrantedScopes::default());
2920 }
2921
2922 #[test]
2925 fn save_then_remove_round_trip() {
2926 {
2928 let temp_dir = {
2929 std::fs::create_dir_all("tmp").ok();
2930 tempfile::TempDir::new_in("tmp").unwrap()
2931 };
2932 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2933
2934 let creds = DriveCredentials {
2935 client_id: "client-1".to_string(),
2936 client_secret: "secret-1".into(),
2937 refresh_token: "refresh-1".into(),
2938 scope: DriveGrantedScopes::READONLY,
2939 };
2940 save_credentials_to(&settings_path, None, &creds).unwrap();
2941
2942 assert!(settings_path.exists());
2943 let val: serde_json::Value =
2944 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2945 assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "client-1");
2946 assert_eq!(val["env"]["DRIVE_CLIENT_SECRET"], "secret-1");
2947 assert_eq!(val["env"]["DRIVE_REFRESH_TOKEN"], "refresh-1");
2948 assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2949
2950 #[cfg(unix)]
2951 {
2952 use std::os::unix::fs::PermissionsExt;
2953 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2954 assert_eq!(mode & 0o777, 0o600);
2955 }
2956 }
2957
2958 {
2960 let temp_dir = {
2961 std::fs::create_dir_all("tmp").ok();
2962 tempfile::TempDir::new_in("tmp").unwrap()
2963 };
2964 let omni_dir = temp_dir.path().join(".omni-dev");
2965 fs::create_dir_all(&omni_dir).unwrap();
2966 let settings_path = omni_dir.join("settings.json");
2967 fs::write(
2968 &settings_path,
2969 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2970 )
2971 .unwrap();
2972
2973 let creds = DriveCredentials {
2974 client_id: "client-2".to_string(),
2975 client_secret: "secret-2".into(),
2976 refresh_token: "refresh-2".into(),
2977 scope: DriveGrantedScopes::READONLY,
2978 };
2979 save_credentials_to(&settings_path, None, &creds).unwrap();
2980
2981 let val: serde_json::Value =
2982 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2983 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2984 assert_eq!(val["extra"], true);
2985 assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2986 }
2987
2988 {
2990 let temp_dir = {
2991 std::fs::create_dir_all("tmp").ok();
2992 tempfile::TempDir::new_in("tmp").unwrap()
2993 };
2994 let omni_dir = temp_dir.path().join(".omni-dev");
2995 fs::create_dir_all(&omni_dir).unwrap();
2996 let settings_path = omni_dir.join("settings.json");
2997 fs::write(
2998 &settings_path,
2999 r#"{"env": {
3000 "DRIVE_CLIENT_ID": "a",
3001 "DRIVE_CLIENT_SECRET": "b",
3002 "DRIVE_REFRESH_TOKEN": "c",
3003 "DRIVE_SCOPE": "d",
3004 "OTHER_KEY": "keep"
3005 }}"#,
3006 )
3007 .unwrap();
3008
3009 let removed = remove_credentials_at(&settings_path, None).unwrap();
3010 assert!(removed);
3011
3012 let val: serde_json::Value =
3013 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3014 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3015 assert!(val["env"].get("DRIVE_CLIENT_SECRET").is_none());
3016 assert!(val["env"].get("DRIVE_REFRESH_TOKEN").is_none());
3017 assert!(val["env"].get("DRIVE_SCOPE").is_none());
3018 assert_eq!(val["env"]["OTHER_KEY"], "keep");
3019 }
3020
3021 {
3023 let temp_dir = {
3024 std::fs::create_dir_all("tmp").ok();
3025 tempfile::TempDir::new_in("tmp").unwrap()
3026 };
3027 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
3028 let removed = remove_credentials_at(&settings_path, None).unwrap();
3029 assert!(!removed);
3030 }
3031 }
3032
3033 #[test]
3035 fn save_then_remove_round_trip_in_profile() {
3036 let temp_dir = {
3037 std::fs::create_dir_all("tmp").ok();
3038 tempfile::TempDir::new_in("tmp").unwrap()
3039 };
3040 let omni_dir = temp_dir.path().join(".omni-dev");
3041 fs::create_dir_all(&omni_dir).unwrap();
3042 let settings_path = omni_dir.join("settings.json");
3043 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
3044
3045 let creds = DriveCredentials {
3046 client_id: "client-p".to_string(),
3047 client_secret: "secret-p".into(),
3048 refresh_token: "refresh-p".into(),
3049 scope: DriveGrantedScopes::READONLY,
3050 };
3051 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
3052
3053 let val: serde_json::Value =
3054 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3055 assert_eq!(
3056 val["profiles"]["work"]["env"]["DRIVE_CLIENT_ID"],
3057 "client-p"
3058 );
3059 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3060 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
3061
3062 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
3063 assert!(removed);
3064 let val: serde_json::Value =
3065 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3066 assert!(val["profiles"]["work"]["env"]
3067 .get("DRIVE_CLIENT_ID")
3068 .is_none());
3069
3070 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
3071 assert!(!removed);
3072 }
3073
3074 #[test]
3078 fn save_and_remove_credentials_resolve_default_settings_path() {
3079 let guard = crate::drive::test_support::EnvGuard::take();
3080 let dir = guard.clear_credentials();
3081
3082 let creds = DriveCredentials {
3083 client_id: "wrapper-client".to_string(),
3084 client_secret: "wrapper-secret".into(),
3085 refresh_token: "wrapper-refresh".into(),
3086 scope: DriveGrantedScopes::READONLY,
3087 };
3088 save_credentials(&creds).unwrap();
3089
3090 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3091 let val: serde_json::Value =
3092 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3093 assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "wrapper-client");
3094
3095 assert!(remove_credentials().unwrap());
3096 let val: serde_json::Value =
3097 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3098 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3099 }
3100
3101 fn owner_only_file(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
3112 let path = dir.path().join(name);
3113 std::fs::write(&path, contents).unwrap();
3114 #[cfg(unix)]
3115 {
3116 use std::os::unix::fs::PermissionsExt;
3117 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3118 }
3119 path.to_str().unwrap().to_string()
3120 }
3121
3122 fn named_settings(account: DriveAccountSettings) -> DriveSettings {
3123 DriveSettings {
3124 accounts: std::iter::once(("work".to_string(), account)).collect(),
3125 ..DriveSettings::default()
3126 }
3127 }
3128
3129 #[test]
3130 fn load_named_credentials_reads_secrets_from_their_files() {
3131 let dir = tempfile::tempdir().unwrap();
3132 let account = DriveAccountSettings {
3133 client_id: Some("id".to_string()),
3134 client_secret_file: Some(owner_only_file(&dir, "cs", "cs-from-file\n")),
3135 refresh_token_file: Some(owner_only_file(&dir, "rt", "rt-from-file\n")),
3136 ..DriveAccountSettings::default()
3137 };
3138 let creds = load_named_credentials(&named_settings(account), "work").unwrap();
3139 assert_eq!(creds.client_secret.expose_secret(), "cs-from-file");
3140 assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
3141 }
3142
3143 #[test]
3144 fn load_named_credentials_rejects_a_field_and_its_file_together() {
3145 let dir = tempfile::tempdir().unwrap();
3146 let account = DriveAccountSettings {
3147 client_id: Some("id".to_string()),
3148 client_secret: Some("cs".to_string()),
3149 refresh_token: Some("rt".to_string()),
3150 refresh_token_file: Some(owner_only_file(&dir, "rt", "rt\n")),
3151 ..DriveAccountSettings::default()
3152 };
3153 let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
3154 assert_eq!(
3155 err.to_string(),
3156 "both drive.accounts.work.refresh_token and drive.accounts.work.refresh_token_file \
3157 are set; set only one of them"
3158 );
3159 }
3160
3161 #[test]
3162 fn load_named_credentials_rejects_a_relative_secret_file() {
3163 let account = DriveAccountSettings {
3164 client_id: Some("id".to_string()),
3165 client_secret_file: Some("secrets/cs".to_string()),
3166 refresh_token: Some("rt".to_string()),
3167 ..DriveAccountSettings::default()
3168 };
3169 let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
3170 assert!(
3171 err.to_string()
3172 .contains("drive.accounts.work.client_secret_file must be an absolute path"),
3173 "{err}"
3174 );
3175 }
3176
3177 #[cfg(feature = "mcp")]
3178 #[test]
3179 fn status_from_named_counts_a_secret_file_as_present() {
3180 let account = DriveAccountSettings {
3181 client_id: Some("id".to_string()),
3182 client_secret_file: Some("/never/read".to_string()),
3183 refresh_token_file: Some("/never/read".to_string()),
3184 ..DriveAccountSettings::default()
3185 };
3186 let status = status_from_named(&named_settings(account), "work");
3187 assert!(status.has_client_secret);
3188 assert!(status.has_refresh_token);
3189 }
3190
3191 #[test]
3192 fn load_credentials_for_named_reads_from_drive_accounts() {
3193 let guard = crate::drive::test_support::EnvGuard::take();
3194 let dir = guard.clear_credentials();
3195 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3196 Settings::upsert_drive_account(
3197 &settings_path,
3198 "work",
3199 &[
3200 (
3201 "client_id",
3202 serde_json::Value::String("work-id".to_string()),
3203 ),
3204 (
3205 "client_secret",
3206 serde_json::Value::String("work-secret".to_string()),
3207 ),
3208 (
3209 "refresh_token",
3210 serde_json::Value::String("work-refresh".to_string()),
3211 ),
3212 (
3213 "scope",
3214 serde_json::Value::String(SCOPE_READONLY.to_string()),
3215 ),
3216 ],
3217 )
3218 .unwrap();
3219
3220 let creds = load_credentials_for(Some("work")).unwrap();
3221 assert_eq!(creds.client_id, "work-id");
3222 assert_eq!(creds.client_secret.expose_secret(), "work-secret");
3223 assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
3224 assert_eq!(creds.scope, DriveGrantedScopes::READONLY);
3225 }
3226
3227 #[test]
3228 fn load_credentials_for_unknown_named_account_errors() {
3229 let guard = crate::drive::test_support::EnvGuard::take();
3230 let dir = guard.clear_credentials();
3231 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3232 Settings::upsert_drive_account(
3233 &settings_path,
3234 "work",
3235 &[(
3236 "client_id",
3237 serde_json::Value::String("work-id".to_string()),
3238 )],
3239 )
3240 .unwrap();
3241
3242 let err = load_credentials_for(Some("bogus")).unwrap_err();
3243 assert!(err.to_string().contains("unknown Drive account 'bogus'"));
3244 }
3245
3246 #[test]
3247 fn load_credentials_for_falls_back_to_env_when_accounts_empty() {
3248 let guard = crate::drive::test_support::EnvGuard::take();
3249 let _dir = guard.clear_credentials();
3250 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
3251 std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
3252 std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
3253
3254 let creds = load_credentials_for(None).unwrap();
3255 assert_eq!(creds.client_id, "literal-id");
3256 }
3257
3258 #[test]
3259 fn load_credentials_for_none_honors_ambient_account_env_var() {
3260 let guard = crate::drive::test_support::EnvGuard::take();
3261 let dir = guard.clear_credentials();
3262 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3263 Settings::upsert_drive_account(
3264 &settings_path,
3265 "work",
3266 &[
3267 (
3268 "client_id",
3269 serde_json::Value::String("work-id".to_string()),
3270 ),
3271 (
3272 "client_secret",
3273 serde_json::Value::String("work-secret".to_string()),
3274 ),
3275 (
3276 "refresh_token",
3277 serde_json::Value::String("work-refresh".to_string()),
3278 ),
3279 ],
3280 )
3281 .unwrap();
3282 std::env::set_var(account::DRIVE_ACCOUNT_ENV, "work");
3283
3284 let creds = load_credentials_for(None).unwrap();
3285 assert_eq!(creds.client_id, "work-id");
3286 }
3287
3288 #[test]
3289 fn remove_credentials_for_named_removes_whole_account() {
3290 let guard = crate::drive::test_support::EnvGuard::take();
3291 let dir = guard.clear_credentials();
3292 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3293 Settings::upsert_drive_account(
3294 &settings_path,
3295 "work",
3296 &[("client_id", serde_json::Value::String("id".to_string()))],
3297 )
3298 .unwrap();
3299
3300 assert!(remove_credentials_for(Some("work")).unwrap());
3301 let val: serde_json::Value =
3302 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3303 assert!(val["drive"]["accounts"].get("work").is_none());
3304 }
3305
3306 #[cfg(feature = "mcp")]
3307 #[test]
3308 fn status_for_named_reports_presence_from_account() {
3309 let guard = crate::drive::test_support::EnvGuard::take();
3310 let dir = guard.clear_credentials();
3311 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3312 Settings::upsert_drive_account(
3313 &settings_path,
3314 "work",
3315 &[
3316 ("client_id", serde_json::Value::String("id".to_string())),
3317 (
3318 "scope",
3319 serde_json::Value::String(SCOPE_READONLY.to_string()),
3320 ),
3321 ],
3322 )
3323 .unwrap();
3324
3325 let status = status_for(Some("work")).unwrap();
3326 assert!(status.has_client_id);
3327 assert!(!status.has_client_secret);
3328 assert!(!status.has_refresh_token);
3329 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
3330 }
3331
3332 #[cfg(feature = "mcp")]
3333 #[test]
3334 fn status_for_unconfigured_matches_status_with_when_accounts_empty() {
3335 let guard = crate::drive::test_support::EnvGuard::take();
3336 let _dir = guard.clear_credentials();
3337 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
3338
3339 let status = status_for(None).unwrap();
3340 assert!(status.has_client_id);
3341 assert!(!status.has_refresh_token);
3342 }
3343
3344 #[test]
3345 fn record_account_email_writes_email_address_only() {
3346 let guard = crate::drive::test_support::EnvGuard::take();
3347 let dir = guard.clear_credentials();
3348 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3349 Settings::upsert_drive_account(
3350 &settings_path,
3351 "work",
3352 &[("client_id", serde_json::Value::String("id".to_string()))],
3353 )
3354 .unwrap();
3355
3356 record_account_email("work", "alice@work.com").unwrap();
3357
3358 let val: serde_json::Value =
3359 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3360 assert_eq!(
3361 val["drive"]["accounts"]["work"]["email_address"],
3362 "alice@work.com"
3363 );
3364 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
3365 }
3366
3367 #[test]
3368 fn record_account_email_does_not_overwrite_an_existing_value() {
3369 let guard = crate::drive::test_support::EnvGuard::take();
3370 let dir = guard.clear_credentials();
3371 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3372 Settings::upsert_drive_account(
3373 &settings_path,
3374 "work",
3375 &[(
3376 "email_address",
3377 serde_json::Value::String("manually-set@work.com".to_string()),
3378 )],
3379 )
3380 .unwrap();
3381
3382 record_account_email("work", "alice@work.com").unwrap();
3383
3384 let val: serde_json::Value =
3385 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3386 assert_eq!(
3387 val["drive"]["accounts"]["work"]["email_address"],
3388 "manually-set@work.com"
3389 );
3390 }
3391
3392 #[test]
3401 fn accounts_empty_load_remove_byte_identical_via_direct_and_for_wrappers() {
3402 let guard = crate::drive::test_support::EnvGuard::take();
3403 let creds = DriveCredentials {
3404 client_id: "id".to_string(),
3405 client_secret: "secret".into(),
3406 refresh_token: "refresh".into(),
3407 scope: DriveGrantedScopes::READONLY,
3408 };
3409
3410 let dir_direct = guard.clear_credentials();
3411 save_credentials(&creds).unwrap();
3412 let direct_written =
3413 fs::read_to_string(dir_direct.path().join(".omni-dev").join("settings.json")).unwrap();
3414 let direct_loaded = load_credentials().unwrap();
3415 let direct_removed = remove_credentials().unwrap();
3416
3417 let dir_for = guard.clear_credentials();
3418 save_credentials(&creds).unwrap();
3419 let for_written =
3420 fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
3421 let for_loaded = load_credentials_for(None).unwrap();
3422 let for_removed = remove_credentials_for(None).unwrap();
3423
3424 assert_eq!(direct_written, for_written);
3425 assert_eq!(direct_loaded.client_id, for_loaded.client_id);
3426 assert_eq!(
3427 direct_loaded.client_secret.expose_secret(),
3428 for_loaded.client_secret.expose_secret()
3429 );
3430 assert_eq!(direct_loaded.scope, for_loaded.scope);
3431 assert_eq!(direct_removed, for_removed);
3432 }
3433}