Skip to main content

omni_dev/drive/
auth.rs

1//! Drive OAuth2 authentication: authorization-code + PKCE login, credential
2//! storage, and in-memory access-token refresh.
3//!
4//! See [ADR-0069](../../../docs/adrs/adr-0069.md) for the design rationale
5//! (applying [ADR-0063](../../../docs/adrs/adr-0063.md), Gmail's OAuth2
6//! credential-storage design, to a second Google API). The loopback-listener
7//! and browser-launch shape follows `crate::gmail::auth`, itself following
8//! the Snowflake client's external-browser SSO flow
9//! (`crate::snowflake::client`'s private `auth` module), extended with PKCE
10//! (RFC 7636), a `state` nonce, and an `error=` branch — none of which a
11//! static-token or SSO-only flow needs.
12
13use 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::settings::{active_profile_from, DriveAccountSettings, DriveSettings, Settings};
35
36/// Environment variable / settings key for the user's Google Cloud OAuth2
37/// client id.
38pub const DRIVE_CLIENT_ID: &str = "DRIVE_CLIENT_ID";
39/// Environment variable / settings key for the user's Google Cloud OAuth2
40/// client secret.
41pub const DRIVE_CLIENT_SECRET: &str = "DRIVE_CLIENT_SECRET";
42/// Environment variable / settings key for the stored OAuth2 refresh token.
43pub const DRIVE_REFRESH_TOKEN: &str = "DRIVE_REFRESH_TOKEN";
44/// Environment variable / settings key recording the scope granted at login.
45pub const DRIVE_SCOPE: &str = "DRIVE_SCOPE";
46/// Environment variable overriding the real Drive API host.
47///
48/// Process-env only — never written to `settings.json` by `auth login`,
49/// unlike the four keys above (`crate::drive::client::DriveClient`'s
50/// default base URL). Useful for:
51/// - Tests that point at a wiremock server (e.g. `http://127.0.0.1:PORT`).
52/// - Environments where outbound traffic must go through a forced proxy.
53///
54/// Mirrors `GMAIL_API_URL` (`crate::gmail::auth`); Drive has no per-tenant
55/// site/region the override is *deriving from* — it's a flat replacement of
56/// the one real host, not a site substitution.
57pub const DRIVE_API_URL: &str = "DRIVE_API_URL";
58
59/// Google's OAuth2 authorization endpoint. Identical to Gmail's — shared
60/// Google infrastructure, not a Drive-specific host.
61const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
62/// Google's OAuth2 token endpoint. Identical to Gmail's.
63const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
64/// The read-only Drive scope — the default.
65///
66/// The only scope requested before [ADR-0070](../../../docs/adrs/adr-0070.md)
67/// reversed [ADR-0069](../../../docs/adrs/adr-0069.md) §2's "no `DriveScope`
68/// enum, read-only by design."
69pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/drive.readonly";
70/// Drive's narrowest write scope: `files.update` on `name`/`parents` only
71/// (rename/move), no file-content access. Opt-in via `--write`.
72pub const SCOPE_METADATA: &str = "https://www.googleapis.com/auth/drive.metadata";
73
74/// How long to wait for the browser sign-in callback before giving up.
75const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
76/// How much slack to leave before an access token's tracked expiry before
77/// proactively refreshing it.
78const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
79/// Upper bound on a trusted `expires_in` from the token endpoint. Comfortably
80/// inside what `TimeDelta::seconds` and `DateTime<Utc>` addition can
81/// represent without panicking, and far beyond any real OAuth token
82/// lifetime — an out-of-range value is clamped rather than trusted, so a
83/// misbehaving or malicious token endpoint can't crash the process (#1531).
84const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
85
86/// The Drive OAuth2 scope granted at login.
87///
88/// Unlike Gmail's readonly/modify split, `Metadata` is requested
89/// *additively* over `ReadOnly`, never as a replacement: `drive.metadata`
90/// alone grants no file-content access, so read commands (`search`, `read`,
91/// export/download) still need `drive.readonly` too. The authorization
92/// request for `Metadata` therefore asks for both scopes together — see
93/// [`build_authorization_url`].
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum DriveScope {
96    /// List/read/export/download files and metadata. Default; never
97    /// requests any mutation.
98    #[default]
99    ReadOnly,
100    /// Everything [`ReadOnly`](Self::ReadOnly) grants, plus `drive.metadata`
101    /// (`files.update` on `name`/`parents` — rename/move).
102    Metadata,
103}
104
105impl DriveScope {
106    /// Returns the wire scope string Google expects for status-reporting /
107    /// storage purposes. For [`Metadata`](Self::Metadata) this is just
108    /// [`SCOPE_METADATA`] — the *additive* `drive.readonly` request shape
109    /// lives in [`build_authorization_url`] alone, since Google's granted-
110    /// scope response and [`from_granted`](Self::from_granted) already
111    /// round-trip correctly off the single [`SCOPE_METADATA`] token.
112    #[must_use]
113    pub fn as_str(self) -> &'static str {
114        match self {
115            Self::ReadOnly => SCOPE_READONLY,
116            Self::Metadata => SCOPE_METADATA,
117        }
118    }
119
120    /// Parses Google's space-separated granted-scope response, treating the
121    /// presence of the metadata scope anywhere in it as
122    /// [`Metadata`](Self::Metadata) and the readonly scope (with no
123    /// metadata) as [`ReadOnly`](Self::ReadOnly).
124    ///
125    /// Returns `None` when neither Drive scope is present — e.g. the user
126    /// left the Drive permission unticked on Google's consent screen — so
127    /// callers can reject the grant instead of silently defaulting to
128    /// [`ReadOnly`](Self::ReadOnly).
129    #[must_use]
130    pub fn from_granted(granted: &str) -> Option<Self> {
131        let tokens: Vec<&str> = granted.split_whitespace().collect();
132        if tokens.contains(&SCOPE_METADATA) {
133            Some(Self::Metadata)
134        } else if tokens.contains(&SCOPE_READONLY) {
135            Some(Self::ReadOnly)
136        } else {
137            None
138        }
139    }
140
141    /// Whether this scope allows mutating calls (`files.update` on
142    /// `name`/`parents` — rename/move).
143    #[must_use]
144    pub fn allows_write(self) -> bool {
145        matches!(self, Self::Metadata)
146    }
147}
148
149/// Drive OAuth2 credentials.
150#[derive(Debug, Clone)]
151pub struct DriveCredentials {
152    /// OAuth2 client id (not secret — visible in the browser's own network
153    /// traffic during login regardless).
154    pub client_id: String,
155    /// OAuth2 client secret (redacted in `Debug` output).
156    pub client_secret: Secret,
157    /// The stored refresh token (redacted in `Debug` output).
158    pub refresh_token: Secret,
159    /// The scope granted at the login that produced this refresh token.
160    pub scope: DriveScope,
161}
162
163/// Secret-free presence/scope report, safe to serialise (e.g. over MCP).
164#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
165pub struct DriveAuthStatus {
166    /// Whether [`DRIVE_CLIENT_ID`] is present.
167    pub has_client_id: bool,
168    /// Whether [`DRIVE_CLIENT_SECRET`] is present.
169    pub has_client_secret: bool,
170    /// Whether [`DRIVE_REFRESH_TOKEN`] is present.
171    pub has_refresh_token: bool,
172    /// The granted scope, if recorded. `None` when unset.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub scope: Option<String>,
175}
176
177/// Resolves the active Drive account for this call (mirrors Gmail's issue
178/// #1500), folding an explicit per-call override together with the ambient
179/// `--account`/[`account::DRIVE_ACCOUNT_ENV`] value. The one seam every
180/// credential CRUD entry point in this module routes through.
181pub(crate) fn resolve(drive: &DriveSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
182    let explicit = fold_explicit(explicit);
183    account::resolve_account(&SystemEnv, drive, explicit.as_deref())
184}
185
186/// Like [`resolve`], but for account-creating writes (`drive auth login`,
187/// `drive auth import`) — see [`account::resolve_account_for_write`] for why
188/// an explicit target need not already exist.
189pub(crate) fn resolve_for_write(
190    drive: &DriveSettings,
191    explicit: Option<&str>,
192) -> Result<ResolvedAccount> {
193    let explicit = fold_explicit(explicit);
194    account::resolve_account_for_write(&SystemEnv, drive, explicit.as_deref())
195}
196
197/// Folds an explicit per-call account override together with the ambient
198/// `--account`/[`account::DRIVE_ACCOUNT_ENV`] value — shared by [`resolve`]
199/// and [`resolve_for_write`].
200fn fold_explicit(explicit: Option<&str>) -> Option<String> {
201    explicit
202        .map(str::to_string)
203        .or_else(|| account::active_drive_account_from(&SystemEnv))
204}
205
206/// Resolves the [`BrowserConfig`] `drive auth login` should open the
207/// authorization URL with, honoring a named account's manual
208/// `browser_command` override and opt-in automatic Chrome-profile
209/// resolution (mirrors Gmail's issue #1505). `explicit` is folded exactly
210/// like [`resolve_for_write`]'s. A [`ResolvedAccount::Unconfigured`] account
211/// (no named accounts configured, or a literal credential env set) always
212/// yields [`BrowserLaunch::Auto`].
213pub(crate) fn resolve_browser_config_for(
214    drive: &DriveSettings,
215    explicit: Option<&str>,
216) -> Result<BrowserConfig> {
217    match resolve_for_write(drive, explicit)? {
218        ResolvedAccount::Unconfigured => Ok(BrowserConfig::default()),
219        ResolvedAccount::Named(name) => build_browser_config(
220            drive.accounts.get(&name),
221            chrome_profile::resolve_launch_command,
222        ),
223    }
224}
225
226/// The pure/injectable core of [`resolve_browser_config_for`] —
227/// `resolve_chrome_profile` is [`chrome_profile::resolve_launch_command`] in
228/// production, a stub in tests, so this stays testable without touching a
229/// real Chrome install.
230///
231/// Precedence:
232/// 1. `account.browser_command` set (non-blank) → used verbatim; a
233///    malformed command is a hard error.
234/// 2. `account.chrome_profile_from_email` set *and* `account.email_address`
235///    set → automatic resolution; any resolution failure (per
236///    `resolve_chrome_profile`'s fail-open contract) falls back to `Auto`.
237/// 3. Otherwise → `Auto`.
238fn build_browser_config(
239    account: Option<&DriveAccountSettings>,
240    resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
241) -> Result<BrowserConfig> {
242    let Some(account) = account else {
243        return Ok(BrowserConfig::default());
244    };
245
246    if let Some(command) = account
247        .browser_command
248        .as_deref()
249        .map(str::trim)
250        .filter(|command| !command.is_empty())
251    {
252        return Ok(BrowserConfig {
253            launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
254            ..BrowserConfig::default()
255        });
256    }
257
258    if account.chrome_profile_from_email {
259        if let Some(email) = account.email_address.as_deref() {
260            if let Some(args) = resolve_chrome_profile(email) {
261                return Ok(BrowserConfig {
262                    launch: BrowserLaunch::Command(args),
263                    ..BrowserConfig::default()
264                });
265            }
266        } else {
267            tracing::info!(
268                "chrome_profile_from_email is set but email_address is not; \
269                 falling back to the default browser"
270            );
271        }
272    }
273
274    Ok(BrowserConfig::default())
275}
276
277/// Loads Drive credentials from environment variables or settings.json.
278///
279/// Environment variables take precedence over the settings file.
280pub fn load_credentials() -> Result<DriveCredentials> {
281    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
282}
283
284/// [`load_credentials`], but honoring the named-account resolution (mirrors
285/// Gmail's issue #1500). `explicit` is the already-resolved `--account`/
286/// [`account::DRIVE_ACCOUNT_ENV`] override, if any (`None` still resolves
287/// the ambient env var — see [`resolve`]). Falls through to
288/// [`load_credentials_with`]'s exact behavior when no named account applies
289/// — an empty `drive.accounts` map or the literal-env bypass both resolve
290/// to [`ResolvedAccount::Unconfigured`], and [`load_credentials_with`]
291/// naturally fails loudly with [`DriveError::CredentialsNotFound`] (whose
292/// message names `drive auth login`) when there is nothing to load.
293pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<DriveCredentials> {
294    let settings = Settings::load().unwrap_or_default();
295    match resolve(&settings.drive, explicit)? {
296        ResolvedAccount::Unconfigured => {
297            let profile = active_profile_from(&SystemEnv);
298            load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
299                settings,
300                profile.as_deref(),
301            ))
302        }
303        ResolvedAccount::Named(name) => load_named_credentials(&settings.drive, &name),
304    }
305}
306
307/// Reads `drive.accounts.<name>` into [`DriveCredentials`], wrapping
308/// `client_secret`/`refresh_token` into [`Secret`] immediately, mirroring
309/// [`load_credentials_with`].
310fn load_named_credentials(drive: &DriveSettings, name: &str) -> Result<DriveCredentials> {
311    let account = drive
312        .accounts
313        .get(name)
314        .ok_or(DriveError::CredentialsNotFound)?;
315    let client_id = account
316        .client_id
317        .clone()
318        .ok_or(DriveError::CredentialsNotFound)?;
319    let client_secret = account
320        .client_secret
321        .clone()
322        .ok_or(DriveError::CredentialsNotFound)?;
323    let refresh_token = account
324        .refresh_token
325        .clone()
326        .ok_or(DriveError::CredentialsNotFound)?;
327    let scope = account
328        .scope
329        .as_deref()
330        .and_then(DriveScope::from_granted)
331        .unwrap_or_default();
332
333    Ok(DriveCredentials {
334        client_id,
335        client_secret: client_secret.into(),
336        refresh_token: refresh_token.into(),
337        scope,
338    })
339}
340
341/// [`load_credentials`] over an injected
342/// [`EnvSource`](crate::utils::env::EnvSource).
343///
344/// Tests pass a pure `MapEnv` so credential resolution is exercised without
345/// mutating the process environment (issue #1030 / STYLE-0028).
346pub(crate) fn load_credentials_with(
347    env: &impl crate::utils::env::EnvSource,
348) -> Result<DriveCredentials> {
349    let client_id = env
350        .var(DRIVE_CLIENT_ID)
351        .ok_or(DriveError::CredentialsNotFound)?;
352    let client_secret = env
353        .var(DRIVE_CLIENT_SECRET)
354        .ok_or(DriveError::CredentialsNotFound)?;
355    let refresh_token = env
356        .var(DRIVE_REFRESH_TOKEN)
357        .ok_or(DriveError::CredentialsNotFound)?;
358    // Unlike login (which rejects an unparseable grant outright), a stored
359    // scope that no longer parses degrades to ReadOnly rather than erroring:
360    // it was already validated when written, and failing closed here is
361    // safe (a stale Metadata silently becoming ReadOnly still fails at the
362    // API, not open).
363    let scope = env
364        .var(DRIVE_SCOPE)
365        .and_then(|s| DriveScope::from_granted(&s))
366        .unwrap_or_default();
367
368    Ok(DriveCredentials {
369        client_id,
370        client_secret: client_secret.into(),
371        refresh_token: refresh_token.into(),
372        scope,
373    })
374}
375
376/// Builds a [`DriveAuthStatus`] from the current settings / environment.
377///
378/// Reports credential presence without leaking any secret values. Safe to
379/// call with no credentials configured.
380pub fn status() -> DriveAuthStatus {
381    status_with(&crate::utils::settings::SettingsEnv::load())
382}
383
384/// [`status`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
385pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> DriveAuthStatus {
386    DriveAuthStatus {
387        has_client_id: env.var(DRIVE_CLIENT_ID).is_some(),
388        has_client_secret: env.var(DRIVE_CLIENT_SECRET).is_some(),
389        has_refresh_token: env.var(DRIVE_REFRESH_TOKEN).is_some(),
390        scope: env.var(DRIVE_SCOPE),
391    }
392}
393
394/// [`status`], but honoring the named-account resolution (mirrors Gmail's
395/// issue #1500). `explicit` is the already-resolved `--account`/
396/// [`account::DRIVE_ACCOUNT_ENV`] override, if any. Unlike [`status`], this
397/// can fail — once named accounts exist, resolution itself can (e.g. an
398/// unknown or ambiguous account) — so callers that want [`status`]'s
399/// never-fails presence report keep calling that instead.
400///
401/// Only compiled with the `mcp` feature — the MCP `drive_auth_status` tool
402/// is its sole consumer; the CLI's `drive auth status` goes through
403/// [`load_credentials_for`] instead.
404#[cfg(feature = "mcp")]
405pub(crate) fn status_for(explicit: Option<&str>) -> Result<DriveAuthStatus> {
406    let settings = Settings::load().unwrap_or_default();
407    match resolve(&settings.drive, explicit)? {
408        ResolvedAccount::Unconfigured => {
409            let profile = active_profile_from(&SystemEnv);
410            Ok(status_with(
411                &crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
412            ))
413        }
414        ResolvedAccount::Named(name) => Ok(status_from_named(&settings.drive, &name)),
415    }
416}
417
418/// Builds a [`DriveAuthStatus`] from `drive.accounts.<name>`'s presence
419/// flags — the named-account counterpart of [`status_with`].
420///
421/// Only compiled with the `mcp` feature — see [`status_for`], its sole
422/// caller.
423#[cfg(feature = "mcp")]
424fn status_from_named(drive: &DriveSettings, name: &str) -> DriveAuthStatus {
425    let account = drive.accounts.get(name);
426    DriveAuthStatus {
427        has_client_id: account.is_some_and(|a| a.client_id.is_some()),
428        has_client_secret: account.is_some_and(|a| a.client_secret.is_some()),
429        has_refresh_token: account.is_some_and(|a| a.refresh_token.is_some()),
430        scope: account.and_then(|a| a.scope.clone()),
431    }
432}
433
434/// Opportunistic `email_address` backfill for `name`, populated by `drive
435/// auth status --all` after a successful live API call. Never used for
436/// authentication, never written by `login`/`import`. A no-op when `name`
437/// already has an `email_address` — an explicit or previously-backfilled
438/// value is never overwritten (mirrors Gmail's issue #1505).
439pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
440    let settings = Settings::load().unwrap_or_default();
441    if settings
442        .drive
443        .accounts
444        .get(name)
445        .is_some_and(|account| account.email_address.is_some())
446    {
447        return Ok(());
448    }
449    Settings::upsert_drive_account(
450        &Settings::get_settings_path()?,
451        name,
452        &[(
453            "email_address",
454            serde_json::Value::String(email.to_string()),
455        )],
456    )
457}
458
459/// Saves Drive credentials to `~/.omni-dev/settings.json`.
460///
461/// Merges the four credential keys into the active profile's `env` map (the
462/// base `env` when no profile is active), preserving all other settings.
463pub fn save_credentials(credentials: &DriveCredentials) -> Result<()> {
464    save_credentials_to(
465        &Settings::get_settings_path()?,
466        active_profile_from(&SystemEnv).as_deref(),
467        credentials,
468    )
469}
470
471/// [`save_credentials`], writing to an explicit settings-file path and env
472/// map (`profiles.<name>.env` when `profile` is `Some`, base `env` otherwise).
473pub(crate) fn save_credentials_to(
474    settings_path: &Path,
475    profile: Option<&str>,
476    credentials: &DriveCredentials,
477) -> Result<()> {
478    Settings::upsert_env_vars_in(
479        settings_path,
480        profile,
481        &[
482            (DRIVE_CLIENT_ID, credentials.client_id.as_str()),
483            (
484                DRIVE_CLIENT_SECRET,
485                credentials.client_secret.expose_secret(),
486            ),
487            (
488                DRIVE_REFRESH_TOKEN,
489                credentials.refresh_token.expose_secret(),
490            ),
491            (DRIVE_SCOPE, credentials.scope.as_str()),
492        ],
493    )
494}
495
496/// The `drive.accounts.<name>` field names/values for `credentials` — the
497/// named-account counterpart of the flat `DRIVE_*` env keys
498/// [`save_credentials_to`] writes.
499fn named_account_vars(credentials: &DriveCredentials) -> [(&str, serde_json::Value); 4] {
500    [
501        (
502            "client_id",
503            serde_json::Value::String(credentials.client_id.clone()),
504        ),
505        (
506            "client_secret",
507            serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
508        ),
509        (
510            "refresh_token",
511            serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
512        ),
513        (
514            "scope",
515            serde_json::Value::String(credentials.scope.as_str().to_string()),
516        ),
517    ]
518}
519
520/// Removes Drive credential keys from `~/.omni-dev/settings.json` — this
521/// *is* `drive auth logout`.
522///
523/// Returns `true` if any Drive key was present and removed, `false`
524/// otherwise.
525pub fn remove_credentials() -> Result<bool> {
526    remove_credentials_at(
527        &Settings::get_settings_path()?,
528        active_profile_from(&SystemEnv).as_deref(),
529    )
530}
531
532/// [`remove_credentials`], operating on an explicit settings-file path and
533/// env map.
534pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
535    Settings::remove_env_vars_in(
536        settings_path,
537        profile,
538        &[
539            DRIVE_CLIENT_ID,
540            DRIVE_CLIENT_SECRET,
541            DRIVE_REFRESH_TOKEN,
542            DRIVE_SCOPE,
543        ],
544    )
545}
546
547/// [`remove_credentials`], but honoring the named-account resolution
548/// (mirrors Gmail's issue #1500). `explicit` is the already-resolved
549/// `--account`/[`account::DRIVE_ACCOUNT_ENV`] override, if any. Removes the
550/// whole `drive.accounts.<name>` entry — an account is coherent as a unit.
551pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
552    let settings = Settings::load().unwrap_or_default();
553    match resolve(&settings.drive, explicit)? {
554        ResolvedAccount::Unconfigured => remove_credentials_at(
555            &Settings::get_settings_path()?,
556            active_profile_from(&SystemEnv).as_deref(),
557        ),
558        ResolvedAccount::Named(name) => {
559            Settings::remove_drive_account(&Settings::get_settings_path()?, &name)
560        }
561    }
562}
563
564// ── Browser launch ──────────────────────────────────────────────────────
565
566/// How to open the authorization URL during login.
567///
568/// Deliberately duplicated from (not shared with) `crate::gmail::auth`'s
569/// identical type (itself duplicated from
570/// [`crate::snowflake::client::config::BrowserLaunch`]) — a small, stable
571/// shape with no existing "generic browser launch" module to promote into;
572/// extract only on a third consumer (see
573/// [ADR-0069](../../../docs/adrs/adr-0069.md) §4).
574#[derive(Clone, Debug, Default)]
575pub enum BrowserLaunch {
576    /// Open with the OS default handler (`open` / `xdg-open` / `start`).
577    #[default]
578    Auto,
579    /// Run a custom command; `{url}` (or a trailing arg) receives the
580    /// authorization URL. Use this to target a specific Chrome profile,
581    /// e.g. `Google Chrome --profile-directory=Profile 1 --new-window {url}`.
582    Command(Vec<String>),
583    /// Do not open a browser; the authorization URL is logged for manual
584    /// opening.
585    Manual,
586}
587
588/// Loopback OAuth2 callback settings.
589#[derive(Clone, Debug)]
590pub struct BrowserConfig {
591    /// How to open the authorization URL.
592    pub launch: BrowserLaunch,
593    /// Bind address for the loopback callback listener.
594    pub callback_addr: IpAddr,
595    /// Bind port for the callback listener (`0` = OS-assigned ephemeral port).
596    pub callback_port: u16,
597}
598
599impl Default for BrowserConfig {
600    fn default() -> Self {
601        Self {
602            launch: BrowserLaunch::Auto,
603            callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
604            callback_port: 0,
605        }
606    }
607}
608
609/// Opens `url` in the configured browser.
610// `{url}` is a literal command placeholder we substitute, not a format string.
611#[allow(clippy::literal_string_with_formatting_args)]
612fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
613    match launch {
614        BrowserLaunch::Manual => {
615            tracing::info!("Open this URL in a browser to sign in to Drive:\n{url}");
616            Ok(())
617        }
618        BrowserLaunch::Command(args) => {
619            let mut parts = args.iter();
620            let program = parts
621                .next()
622                .ok_or_else(|| DriveError::InvalidBrowserCommand("empty browser command".into()))?;
623            let mut command = Command::new(program);
624            let mut placed = false;
625            for arg in parts {
626                if arg.contains("{url}") {
627                    command.arg(arg.replace("{url}", url));
628                    placed = true;
629                } else {
630                    command.arg(arg);
631                }
632            }
633            if !placed {
634                command.arg(url);
635            }
636            spawn_detached(command)
637        }
638        BrowserLaunch::Auto => {
639            let program = if cfg!(target_os = "macos") {
640                "open"
641            } else if cfg!(target_os = "windows") {
642                "explorer"
643            } else {
644                "xdg-open"
645            };
646            let mut command = Command::new(program);
647            command.arg(url);
648            spawn_detached(command)
649        }
650    }
651}
652
653/// Spawns a browser command detached from this process's stdio.
654fn spawn_detached(mut command: Command) -> Result<()> {
655    command
656        .stdin(Stdio::null())
657        .stdout(Stdio::null())
658        .stderr(Stdio::null())
659        .spawn()
660        .map(|_| ())
661        .context("Failed to launch the browser")
662}
663
664// ── PKCE + state ────────────────────────────────────────────────────────
665
666/// A pending login's PKCE verifier and CSRF `state` nonce, generated fresh
667/// per login attempt and never persisted.
668struct PendingLogin {
669    state: String,
670    code_verifier: String,
671}
672
673fn generate_pending_login() -> PendingLogin {
674    PendingLogin {
675        state: crate::browser::auth::generate_token(),
676        code_verifier: crate::browser::auth::generate_token(),
677    }
678}
679
680/// Derives the PKCE `code_challenge` (RFC 7636, `S256` method) from a
681/// `code_verifier`.
682fn code_challenge(code_verifier: &str) -> String {
683    let digest = Sha256::digest(code_verifier.as_bytes());
684    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
685}
686
687fn build_authorization_url(
688    client_id: &str,
689    redirect_uri: &str,
690    scope: DriveScope,
691    state: &str,
692    code_challenge: &str,
693) -> Result<Url> {
694    let mut url =
695        Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Drive authorization endpoint")?;
696    // Metadata is requested *additively* over ReadOnly — see DriveScope's
697    // doc comment for why a single `scope.as_str()` isn't enough here.
698    let requested_scope = match scope {
699        DriveScope::ReadOnly => SCOPE_READONLY.to_string(),
700        DriveScope::Metadata => format!("{SCOPE_READONLY} {SCOPE_METADATA}"),
701    };
702    url.query_pairs_mut()
703        .append_pair("client_id", client_id)
704        .append_pair("redirect_uri", redirect_uri)
705        .append_pair("response_type", "code")
706        .append_pair("scope", &requested_scope)
707        .append_pair("state", state)
708        .append_pair("code_challenge", code_challenge)
709        .append_pair("code_challenge_method", "S256")
710        .append_pair("access_type", "offline")
711        // Without forcing re-consent, Google may not re-issue a refresh
712        // token on a second login — which would silently break re-auth
713        // after the 7-day testing-mode refresh-token expiry.
714        .append_pair("prompt", "consent");
715    Ok(url)
716}
717
718// ── Loopback callback capture ───────────────────────────────────────────
719
720/// The parsed loopback callback: either `code`+`state`, or an `error`
721/// (optionally with `error_description`).
722#[derive(Debug)]
723pub(crate) struct CallbackResult {
724    code: Option<String>,
725    state: Option<String>,
726    error: Option<String>,
727    error_description: Option<String>,
728}
729
730/// Binds the loopback callback listener, returning it along with the
731/// OS-assigned port so the authorization URL's `redirect_uri` can be built
732/// before the browser is opened.
733pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
734    let listener = TcpListener::bind(SocketAddr::new(
735        browser.callback_addr,
736        browser.callback_port,
737    ))
738    .await
739    .context("Failed to start the local OAuth callback listener")?;
740    let port = listener
741        .local_addr()
742        .context("Failed to read the callback listener's port")?
743        .port();
744    Ok((listener, port))
745}
746
747/// Waits for the browser's callback connection using the default
748/// [`CALLBACK_TIMEOUT`].
749pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
750    wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
751}
752
753/// Accepts one loopback connection and extracts the OAuth callback's query
754/// parameters from the redirected `GET` request line.
755///
756/// Never logs the raw request or query string — only that a callback was
757/// received — so the authorization `code` can never reach the request log
758/// via this path (see ADR-0063's redaction discussion).
759pub(crate) async fn wait_for_callback_with_timeout(
760    listener: TcpListener,
761    timeout: Duration,
762) -> Result<CallbackResult> {
763    let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
764        .await
765        .map_err(|_| DriveError::CallbackTimeout(timeout.as_secs()))?
766        .context("Failed to accept the browser's callback connection")?;
767
768    let mut buf = vec![0u8; 8192];
769    let n = stream
770        .read(&mut buf)
771        .await
772        .context("Failed to read the callback request")?;
773    let request = String::from_utf8_lossy(&buf[..n]);
774
775    let result = parse_callback(&request).ok_or(DriveError::MalformedCallback)?;
776    tracing::info!("Drive OAuth callback received");
777
778    let body = if result.error.is_some() {
779        "<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
780    } else {
781        "<html><body>Drive sign-in complete. You can close this tab.</body></html>"
782    };
783    let response =
784        format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
785    let _ = stream.write_all(response.as_bytes()).await;
786    let _ = stream.flush().await;
787
788    Ok(result)
789}
790
791/// Extracts `code`/`state`/`error`/`error_description` from an HTTP
792/// request's first line only — headers and body are never inspected.
793fn parse_callback(request: &str) -> Option<CallbackResult> {
794    let first_line = request.lines().next()?;
795    let path = first_line.split_whitespace().nth(1)?; // "/?code=…&state=…"
796    let query = path.split_once('?')?.1;
797
798    let mut result = CallbackResult {
799        code: None,
800        state: None,
801        error: None,
802        error_description: None,
803    };
804    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
805        match key.as_ref() {
806            "code" => result.code = Some(value.into_owned()),
807            "state" => result.state = Some(value.into_owned()),
808            "error" => result.error = Some(value.into_owned()),
809            "error_description" => result.error_description = Some(value.into_owned()),
810            _ => {}
811        }
812    }
813    Some(result)
814}
815
816// ── Token exchange / refresh ────────────────────────────────────────────
817
818#[derive(Debug, Deserialize)]
819struct TokenResponse {
820    access_token: String,
821    #[serde(default)]
822    refresh_token: Option<String>,
823    expires_in: i64,
824    #[serde(default)]
825    scope: Option<String>,
826}
827
828#[derive(Debug, Deserialize)]
829struct TokenErrorResponse {
830    error: String,
831    #[serde(default)]
832    error_description: Option<String>,
833}
834
835async fn exchange_code_for_tokens(
836    http: &reqwest::Client,
837    token_endpoint: &str,
838    client_id: &str,
839    client_secret: &str,
840    code: &str,
841    code_verifier: &str,
842    redirect_uri: &str,
843) -> Result<TokenResponse> {
844    let params = [
845        ("grant_type", "authorization_code"),
846        ("code", code),
847        ("client_id", client_id),
848        ("client_secret", client_secret),
849        ("redirect_uri", redirect_uri),
850        ("code_verifier", code_verifier),
851    ];
852    post_token_request(http, token_endpoint, &params, GrantContext::CodeExchange).await
853}
854
855async fn refresh_access_token(
856    http: &reqwest::Client,
857    token_endpoint: &str,
858    client_id: &str,
859    client_secret: &str,
860    refresh_token: &str,
861) -> Result<TokenResponse> {
862    let params = [
863        ("grant_type", "refresh_token"),
864        ("refresh_token", refresh_token),
865        ("client_id", client_id),
866        ("client_secret", client_secret),
867    ];
868    post_token_request(http, token_endpoint, &params, GrantContext::Refresh).await
869}
870
871/// POSTs a token request. All secrets travel in the `.form(...)` body, never
872/// the URL — `token_endpoint` carries no query string, so the request-log's
873/// URL redaction has nothing to redact and nothing to miss either.
874async fn post_token_request(
875    http: &reqwest::Client,
876    token_endpoint: &str,
877    params: &[(&str, &str)],
878    context: GrantContext,
879) -> Result<TokenResponse> {
880    let started = std::time::Instant::now();
881    let result = http.post(token_endpoint).form(params).send().await;
882    request_log::record_http_result("drive", "POST", token_endpoint, started, &result);
883    let response = result.context("Failed to send token request to Google")?;
884
885    if !response.status().is_success() {
886        let body = response.text().await.unwrap_or_default();
887        if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
888            if err.error == "invalid_grant" {
889                return Err(DriveError::InvalidGrant(context).into());
890            }
891            return Err(anyhow::anyhow!(
892                "Google token endpoint rejected the request: {} ({})",
893                err.error,
894                err.error_description.unwrap_or_default()
895            ));
896        }
897        return Err(anyhow::anyhow!(
898            "Google token endpoint returned an unparsable error body: {body}"
899        ));
900    }
901
902    response
903        .json::<TokenResponse>()
904        .await
905        .context("Failed to parse Google's token response")
906}
907
908// ── Session (in-memory access-token lifecycle) ──────────────────────────
909
910/// The mutable access-token state, refreshed by [`DriveSession::refresh_locked`].
911struct TokenState {
912    access_token: Secret,
913    expires_at: DateTime<Utc>,
914}
915
916/// A live Drive OAuth2 session: holds the refresh token and the current
917/// in-memory access token, refreshing on demand.
918///
919/// Uses [`tokio::sync::Mutex`] (not `std::sync::Mutex`) held *across* the
920/// refresh network call — mirrors `crate::gmail::auth::GmailSession`'s
921/// explicit single-flight-refresh design (issue #1465's "concurrent callers
922/// don't stampede" requirement): a second concurrent caller blocks on this
923/// mutex and, once unblocked, observes the already-refreshed token instead
924/// of issuing a second POST.
925pub struct DriveSession {
926    http: reqwest::Client,
927    client_id: String,
928    client_secret: Secret,
929    refresh_token: Secret,
930    token_endpoint: String,
931    state: tokio::sync::Mutex<TokenState>,
932}
933
934impl DriveSession {
935    /// Creates a session against Google's real token endpoint.
936    pub(crate) fn new(http: reqwest::Client, credentials: &DriveCredentials) -> Self {
937        Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
938    }
939
940    /// [`new`](Self::new) against an explicit token endpoint — the test seam
941    /// for pointing at a wiremock server.
942    pub(crate) fn new_with_token_endpoint(
943        http: reqwest::Client,
944        credentials: &DriveCredentials,
945        token_endpoint: &str,
946    ) -> Self {
947        Self {
948            http,
949            client_id: credentials.client_id.clone(),
950            client_secret: credentials.client_secret.clone(),
951            refresh_token: credentials.refresh_token.clone(),
952            token_endpoint: token_endpoint.to_string(),
953            state: tokio::sync::Mutex::new(TokenState {
954                access_token: Secret::new(""),
955                // No access token is ever persisted (ADR-0063 Decision 2), so
956                // every fresh session starts expired and refreshes on its
957                // very first call.
958                expires_at: DateTime::<Utc>::MIN_UTC,
959            }),
960        }
961    }
962
963    /// Returns a valid access token, refreshing proactively when the
964    /// tracked expiry is within [`REFRESH_SKEW`].
965    pub(crate) async fn access_token(&self) -> Result<Secret> {
966        let mut state = self.state.lock().await;
967        if Utc::now() + REFRESH_SKEW >= state.expires_at {
968            self.refresh_locked(&mut state).await?;
969        }
970        Ok(state.access_token.clone())
971    }
972
973    /// Forces a refresh, but only if `observed` is still the current token —
974    /// i.e. no other caller already refreshed while this caller was waiting
975    /// on the lock. Used as the reactive safety net after an HTTP 401 (clock
976    /// skew, or server-side revocation the proactive check can't see).
977    pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
978        let mut state = self.state.lock().await;
979        if state.access_token != *observed {
980            return Ok(state.access_token.clone());
981        }
982        self.refresh_locked(&mut state).await?;
983        Ok(state.access_token.clone())
984    }
985
986    async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
987        let response = refresh_access_token(
988            &self.http,
989            &self.token_endpoint,
990            &self.client_id,
991            self.client_secret.expose_secret(),
992            self.refresh_token.expose_secret(),
993        )
994        .await?;
995        state.access_token = response.access_token.into();
996        let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
997        state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
998        Ok(())
999    }
1000}
1001
1002// ── Login orchestration ─────────────────────────────────────────────────
1003
1004/// Runs the OAuth2 authorization-code + PKCE login flow, persisting the
1005/// resulting refresh token to `~/.omni-dev/settings.json`.
1006pub async fn login(
1007    client_id: &str,
1008    client_secret: &Secret,
1009    scope: DriveScope,
1010    browser: &BrowserConfig,
1011) -> Result<DriveAuthStatus> {
1012    login_to(
1013        &Settings::get_settings_path()?,
1014        active_profile_from(&SystemEnv).as_deref(),
1015        client_id,
1016        client_secret,
1017        scope,
1018        browser,
1019        TOKEN_ENDPOINT,
1020    )
1021    .await
1022}
1023
1024/// [`login`], writing to an explicit settings-file path/profile and against
1025/// an explicit token endpoint — the test seam for a wiremock server.
1026pub(crate) async fn login_to(
1027    settings_path: &Path,
1028    profile: Option<&str>,
1029    client_id: &str,
1030    client_secret: &Secret,
1031    scope: DriveScope,
1032    browser: &BrowserConfig,
1033    token_endpoint: &str,
1034) -> Result<DriveAuthStatus> {
1035    let credentials =
1036        run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1037    save_credentials_to(settings_path, profile, &credentials)?;
1038    Ok(status_from_credentials(&credentials))
1039}
1040
1041/// [`login`], but honoring the named-account resolution (mirrors Gmail's
1042/// issue #1500): runs the same OAuth2 flow, then persists to
1043/// `drive.accounts.<name>` when a named account is active instead of the
1044/// legacy `env`/profile map. `explicit` is the already-resolved
1045/// `--account`/[`account::DRIVE_ACCOUNT_ENV`] override, if any — resolved
1046/// via [`resolve_for_write`], so an explicit name need not already be
1047/// configured (this is how a new account is created).
1048pub(crate) async fn login_for(
1049    explicit: Option<&str>,
1050    client_id: &str,
1051    client_secret: &Secret,
1052    scope: DriveScope,
1053    browser: &BrowserConfig,
1054) -> Result<DriveAuthStatus> {
1055    let settings = Settings::load().unwrap_or_default();
1056    match resolve_for_write(&settings.drive, explicit)? {
1057        ResolvedAccount::Unconfigured => {
1058            login_to(
1059                &Settings::get_settings_path()?,
1060                active_profile_from(&SystemEnv).as_deref(),
1061                client_id,
1062                client_secret,
1063                scope,
1064                browser,
1065                TOKEN_ENDPOINT,
1066            )
1067            .await
1068        }
1069        ResolvedAccount::Named(name) => {
1070            let credentials =
1071                run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1072            Settings::upsert_drive_account(
1073                &Settings::get_settings_path()?,
1074                &name,
1075                &named_account_vars(&credentials),
1076            )?;
1077            Ok(status_from_credentials(&credentials))
1078        }
1079    }
1080}
1081
1082/// Runs the OAuth2 authorization-code + PKCE flow against `token_endpoint`
1083/// and returns the resulting credentials, without persisting them — the
1084/// shared core both [`login_to`] (legacy path) and [`login_for`]'s Named
1085/// branch build on.
1086async fn run_login_flow(
1087    client_id: &str,
1088    client_secret: &Secret,
1089    scope: DriveScope,
1090    browser: &BrowserConfig,
1091    token_endpoint: &str,
1092) -> Result<DriveCredentials> {
1093    let (listener, port) = bind_callback_listener(browser).await?;
1094    let redirect_uri = format!("http://127.0.0.1:{port}");
1095
1096    let pending = generate_pending_login();
1097    let challenge = code_challenge(&pending.code_verifier);
1098    let auth_url =
1099        build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1100    open_browser(&browser.launch, auth_url.as_str())?;
1101
1102    let callback = wait_for_callback(listener).await?;
1103    if let Some(error) = callback.error {
1104        return Err(DriveError::authorization_denied(
1105            &error,
1106            callback.error_description.as_deref(),
1107        )
1108        .into());
1109    }
1110    let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1111        return Err(DriveError::MalformedCallback.into());
1112    };
1113    // Plain equality, not constant-time: `state` is a CSRF nonce carried in
1114    // a browser-visible URL, not a secret — there's nothing for a timing
1115    // side-channel to extract here (unlike `constant_time_eq`'s real use
1116    // guarding a bridge auth token in `src/browser/auth.rs`).
1117    if returned_state != pending.state {
1118        return Err(DriveError::StateMismatch.into());
1119    }
1120
1121    let http = reqwest::Client::builder()
1122        .connect_timeout(crate::utils::http::connect_timeout())
1123        .read_timeout(crate::utils::http::read_timeout())
1124        .build()
1125        .context("Failed to build HTTP client")?;
1126    let tokens = exchange_code_for_tokens(
1127        &http,
1128        token_endpoint,
1129        client_id,
1130        client_secret.expose_secret(),
1131        &code,
1132        &pending.code_verifier,
1133        &redirect_uri,
1134    )
1135    .await?;
1136    let refresh_token = tokens
1137        .refresh_token
1138        .ok_or(DriveError::MalformedTokenResponse("refresh_token"))?;
1139    let granted_raw = tokens.scope.unwrap_or_default();
1140    let granted_scope = DriveScope::from_granted(&granted_raw).ok_or_else(|| {
1141        let received = if granted_raw.trim().is_empty() {
1142            "none".to_string()
1143        } else {
1144            granted_raw
1145                .split_whitespace()
1146                .collect::<Vec<_>>()
1147                .join(", ")
1148        };
1149        DriveError::NoScopeGranted(received)
1150    })?;
1151
1152    Ok(DriveCredentials {
1153        client_id: client_id.to_string(),
1154        client_secret: client_secret.clone(),
1155        refresh_token: refresh_token.into(),
1156        scope: granted_scope,
1157    })
1158}
1159
1160/// Builds the "just authenticated" [`DriveAuthStatus`] from freshly-obtained
1161/// `credentials` (all fields present by construction).
1162fn status_from_credentials(credentials: &DriveCredentials) -> DriveAuthStatus {
1163    DriveAuthStatus {
1164        has_client_id: true,
1165        has_client_secret: true,
1166        has_refresh_token: true,
1167        scope: Some(credentials.scope.as_str().to_string()),
1168    }
1169}
1170
1171#[cfg(test)]
1172#[allow(clippy::unwrap_used, clippy::expect_used)]
1173mod tests {
1174    use std::fs;
1175    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1176    use std::sync::Arc;
1177
1178    use super::*;
1179
1180    // ── Pure helpers ─────────────────────────────────────────────────
1181
1182    #[test]
1183    fn code_challenge_matches_rfc_7636_test_vector() {
1184        // RFC 7636 Appendix B.1.
1185        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1186        let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1187        assert_eq!(code_challenge(verifier), expected);
1188    }
1189
1190    #[test]
1191    fn code_challenge_output_is_url_safe_no_padding() {
1192        let challenge = code_challenge("some-verifier-value");
1193        assert!(!challenge.contains('+'));
1194        assert!(!challenge.contains('/'));
1195        assert!(!challenge.contains('='));
1196    }
1197
1198    #[test]
1199    fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1200        let pending = generate_pending_login();
1201        assert_ne!(pending.state, pending.code_verifier);
1202        assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1203        assert!(pending
1204            .code_verifier
1205            .chars()
1206            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1207    }
1208
1209    #[test]
1210    fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1211        let url = build_authorization_url(
1212            "client-123",
1213            "http://127.0.0.1:5555",
1214            DriveScope::ReadOnly,
1215            "state-abc",
1216            "challenge-xyz",
1217        )
1218        .unwrap();
1219        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1220        assert_eq!(query.get("client_id").unwrap(), "client-123");
1221        assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1222        assert_eq!(query.get("response_type").unwrap(), "code");
1223        assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1224        assert_eq!(query.get("state").unwrap(), "state-abc");
1225        assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1226        assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1227        assert_eq!(query.get("access_type").unwrap(), "offline");
1228        assert_eq!(query.get("prompt").unwrap(), "consent");
1229    }
1230
1231    #[test]
1232    fn build_authorization_url_uses_additive_scope_when_metadata_requested() {
1233        let url = build_authorization_url(
1234            "client-123",
1235            "http://127.0.0.1:5555",
1236            DriveScope::Metadata,
1237            "state-abc",
1238            "challenge-xyz",
1239        )
1240        .unwrap();
1241        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1242        assert_eq!(
1243            query.get("scope").unwrap(),
1244            &format!("{SCOPE_READONLY} {SCOPE_METADATA}")
1245        );
1246    }
1247
1248    #[test]
1249    fn parse_callback_extracts_code_and_state() {
1250        let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1251        let result = parse_callback(request).unwrap();
1252        assert_eq!(result.code.as_deref(), Some("abc123"));
1253        assert_eq!(result.state.as_deref(), Some("xyz789"));
1254        assert!(result.error.is_none());
1255    }
1256
1257    #[test]
1258    fn parse_callback_extracts_error_and_error_description() {
1259        let request =
1260            "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1261        let result = parse_callback(request).unwrap();
1262        assert_eq!(result.error.as_deref(), Some("access_denied"));
1263        assert_eq!(result.error_description.as_deref(), Some("user declined"));
1264    }
1265
1266    #[test]
1267    fn parse_callback_missing_query_string_is_none() {
1268        assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1269        assert!(parse_callback("garbage").is_none());
1270    }
1271
1272    #[test]
1273    fn parse_callback_ignores_unrecognized_query_keys() {
1274        let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1275        let result = parse_callback(request).unwrap();
1276        assert_eq!(result.code.as_deref(), Some("abc"));
1277        assert_eq!(result.state.as_deref(), Some("xyz"));
1278    }
1279
1280    #[test]
1281    fn open_browser_manual_logs_and_succeeds() {
1282        assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1283    }
1284
1285    #[test]
1286    fn open_browser_command_substitutes_url_placeholder() {
1287        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1288        assert!(open_browser(&launch, "https://example/auth").is_ok());
1289    }
1290
1291    #[test]
1292    fn open_browser_command_appends_url_when_no_placeholder() {
1293        let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1294        assert!(open_browser(&launch, "https://example/auth").is_ok());
1295    }
1296
1297    #[test]
1298    fn open_browser_command_passes_through_args_without_the_placeholder() {
1299        // A trailing flag with no `{url}` substring (e.g. `--verbose`) is
1300        // passed to the command unmodified, and the URL is still appended
1301        // since no arg claimed the placeholder.
1302        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1303        assert!(open_browser(&launch, "https://example/auth").is_ok());
1304    }
1305
1306    #[test]
1307    fn open_browser_command_rejects_empty_args() {
1308        let launch = BrowserLaunch::Command(vec![]);
1309        let err = open_browser(&launch, "u").unwrap_err();
1310        assert!(err.to_string().contains("empty browser command"));
1311    }
1312
1313    // ── named_account_vars ───────────────────────────────────────────────
1314
1315    #[test]
1316    fn named_account_vars_maps_credentials_to_json_string_values() {
1317        let credentials = DriveCredentials {
1318            client_id: "client-1".to_string(),
1319            client_secret: Secret::new("secret-1"),
1320            refresh_token: Secret::new("refresh-1"),
1321            scope: DriveScope::ReadOnly,
1322        };
1323        assert_eq!(
1324            named_account_vars(&credentials),
1325            [
1326                (
1327                    "client_id",
1328                    serde_json::Value::String("client-1".to_string())
1329                ),
1330                (
1331                    "client_secret",
1332                    serde_json::Value::String("secret-1".to_string())
1333                ),
1334                (
1335                    "refresh_token",
1336                    serde_json::Value::String("refresh-1".to_string())
1337                ),
1338                (
1339                    "scope",
1340                    serde_json::Value::String(SCOPE_READONLY.to_string())
1341                ),
1342            ]
1343        );
1344    }
1345
1346    // ── build_browser_config (mirrors Gmail's issue #1505) ──────────────
1347
1348    fn assert_is_auto(config: BrowserConfig) {
1349        assert!(matches!(config.launch, BrowserLaunch::Auto));
1350    }
1351
1352    #[test]
1353    fn build_browser_config_defaults_to_auto_with_no_account() {
1354        assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1355    }
1356
1357    #[test]
1358    fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1359        let account = DriveAccountSettings {
1360            email_address: Some("alice@example.com".to_string()),
1361            ..DriveAccountSettings::default()
1362        };
1363        // chrome_profile_from_email is false, so the resolver must never run
1364        // even though email_address is set.
1365        assert_is_auto(
1366            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1367        );
1368    }
1369
1370    #[test]
1371    fn build_browser_config_uses_browser_command_verbatim() {
1372        let account = DriveAccountSettings {
1373            browser_command: Some("chrome --new-window {url}".to_string()),
1374            ..DriveAccountSettings::default()
1375        };
1376        let config =
1377            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1378        assert!(matches!(
1379            config.launch,
1380            BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1381        ));
1382    }
1383
1384    #[test]
1385    fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1386        let account = DriveAccountSettings {
1387            browser_command: Some("chrome {url}".to_string()),
1388            chrome_profile_from_email: true,
1389            email_address: Some("alice@example.com".to_string()),
1390            ..DriveAccountSettings::default()
1391        };
1392        let config =
1393            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1394        assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1395    }
1396
1397    #[test]
1398    fn build_browser_config_rejects_a_malformed_browser_command() {
1399        let account = DriveAccountSettings {
1400            browser_command: Some("chrome \"--flag".to_string()),
1401            ..DriveAccountSettings::default()
1402        };
1403        let err =
1404            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1405        assert!(err.to_string().contains("browser_command"));
1406    }
1407
1408    #[test]
1409    fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1410        let account = DriveAccountSettings {
1411            chrome_profile_from_email: true,
1412            email_address: Some("alice@example.com".to_string()),
1413            ..DriveAccountSettings::default()
1414        };
1415        let config = build_browser_config(Some(&account), |email| {
1416            assert_eq!(email, "alice@example.com");
1417            Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1418        })
1419        .unwrap();
1420        assert!(matches!(
1421            config.launch,
1422            BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1423        ));
1424    }
1425
1426    #[test]
1427    fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1428        let account = DriveAccountSettings {
1429            chrome_profile_from_email: true,
1430            email_address: Some("alice@example.com".to_string()),
1431            ..DriveAccountSettings::default()
1432        };
1433        assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1434    }
1435
1436    #[test]
1437    fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1438        let account = DriveAccountSettings {
1439            chrome_profile_from_email: true,
1440            ..DriveAccountSettings::default()
1441        };
1442        assert_is_auto(
1443            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1444        );
1445    }
1446
1447    // ── resolve_browser_config_for (mirrors Gmail's issue #1505) ────────
1448
1449    #[test]
1450    fn resolve_browser_config_for_unconfigured_account_defaults_to_auto() {
1451        let guard = crate::drive::test_support::EnvGuard::take();
1452        let _dir = guard.clear_credentials();
1453        std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
1454        std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
1455        std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
1456
1457        let drive = DriveSettings::default();
1458        assert_is_auto(resolve_browser_config_for(&drive, None).unwrap());
1459    }
1460
1461    #[test]
1462    fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1463        let guard = crate::drive::test_support::EnvGuard::take();
1464        let _dir = guard.clear_credentials();
1465
1466        let mut drive = DriveSettings::default();
1467        drive.accounts.insert(
1468            "work".to_string(),
1469            DriveAccountSettings {
1470                email_address: Some("alice@example.com".to_string()),
1471                ..DriveAccountSettings::default()
1472            },
1473        );
1474
1475        // chrome_profile_from_email is false, so this never touches the
1476        // real chrome_profile::resolve_launch_command resolver.
1477        assert_is_auto(resolve_browser_config_for(&drive, Some("work")).unwrap());
1478    }
1479
1480    // ── Loopback listener (real sockets, no wiremock) ───────────────────
1481
1482    #[tokio::test]
1483    async fn wait_for_callback_times_out_when_nothing_connects() {
1484        let browser = BrowserConfig::default();
1485        let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1486        let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1487            .await
1488            .unwrap_err();
1489        assert!(matches!(
1490            err.downcast_ref::<DriveError>(),
1491            Some(DriveError::CallbackTimeout(_))
1492        ));
1493    }
1494
1495    #[tokio::test]
1496    async fn wait_for_callback_reads_a_real_connection() {
1497        let browser = BrowserConfig::default();
1498        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1499
1500        let client = tokio::spawn(async move {
1501            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1502                .await
1503                .unwrap();
1504            stream
1505                .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1506                .await
1507                .unwrap();
1508        });
1509
1510        let result = wait_for_callback(listener).await.unwrap();
1511        client.await.unwrap();
1512        assert_eq!(result.code.as_deref(), Some("abc"));
1513        assert_eq!(result.state.as_deref(), Some("xyz"));
1514    }
1515
1516    #[tokio::test]
1517    async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1518        let browser = BrowserConfig::default();
1519        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1520
1521        let client = tokio::spawn(async move {
1522            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1523                .await
1524                .unwrap();
1525            stream.write_all(b"not an http request").await.unwrap();
1526        });
1527
1528        let err = wait_for_callback(listener).await.unwrap_err();
1529        client.await.unwrap();
1530        assert!(matches!(
1531            err.downcast_ref::<DriveError>(),
1532            Some(DriveError::MalformedCallback)
1533        ));
1534    }
1535
1536    // ── Token exchange / refresh (wiremock) ─────────────────────────────
1537
1538    #[tokio::test]
1539    async fn exchange_code_for_tokens_posts_expected_form_body() {
1540        let server = wiremock::MockServer::start().await;
1541        wiremock::Mock::given(wiremock::matchers::method("POST"))
1542            .and(wiremock::matchers::path("/token"))
1543            .and(wiremock::matchers::body_string_contains(
1544                "grant_type=authorization_code",
1545            ))
1546            .and(wiremock::matchers::body_string_contains(
1547                "code_verifier=verifier-1",
1548            ))
1549            .and(wiremock::matchers::body_string_contains(
1550                "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1551            ))
1552            .respond_with(
1553                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1554                    "access_token": "at-1",
1555                    "refresh_token": "rt-1",
1556                    "expires_in": 3600,
1557                    "scope": SCOPE_READONLY,
1558                })),
1559            )
1560            .expect(1)
1561            .mount(&server)
1562            .await;
1563
1564        let http = reqwest::Client::new();
1565        let token_endpoint = format!("{}/token", server.uri());
1566        let response = exchange_code_for_tokens(
1567            &http,
1568            &token_endpoint,
1569            "client-1",
1570            "secret-1",
1571            "code-1",
1572            "verifier-1",
1573            "http://127.0.0.1:9999",
1574        )
1575        .await
1576        .unwrap();
1577        assert_eq!(response.access_token, "at-1");
1578        assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1579    }
1580
1581    #[tokio::test]
1582    async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1583        let server = wiremock::MockServer::start().await;
1584        wiremock::Mock::given(wiremock::matchers::method("POST"))
1585            .respond_with(
1586                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1587                    "error": "invalid_grant",
1588                    "error_description": "Bad Request",
1589                })),
1590            )
1591            .mount(&server)
1592            .await;
1593
1594        let http = reqwest::Client::new();
1595        let err = exchange_code_for_tokens(
1596            &http,
1597            &server.uri(),
1598            "c",
1599            "s",
1600            "code",
1601            "verifier",
1602            "http://127.0.0.1:1",
1603        )
1604        .await
1605        .unwrap_err();
1606        assert!(err.to_string().contains("PKCE"));
1607    }
1608
1609    // ── login_to (end-to-end: state mismatch / access_denied) ───────────
1610    //
1611    // These exercise `login_to` itself rather than its sub-components in
1612    // isolation, so a regression in how it wires the loopback callback into
1613    // the state-mismatch/access_denied branches would actually be caught.
1614    // The callback port is picked by binding-then-dropping a std listener
1615    // (a well-known "reserve a free port" trick) so the test's connector
1616    // task can dial it directly — `login_to` binds the real listener before
1617    // opening the browser, so the connector retries briefly to cover the
1618    // small window before that bind completes.
1619    //
1620    // That reserve-then-drop is itself a TOCTOU race against the rest of the
1621    // parallel test suite: another test can grab the same ephemeral port
1622    // before `login_to`'s own bind runs, which fails outright rather than
1623    // retrying (mirrors Gmail's issue #1489). `run_with_port_retry` bounds a
1624    // retry of the whole reserve/connect/bind attempt on exactly that
1625    // failure, so a lost race just tries again with a fresh port instead of
1626    // flaking.
1627
1628    async fn connect_and_send(port: u16, request_line: &[u8]) {
1629        let mut stream = loop {
1630            match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1631                Ok(stream) => break stream,
1632                Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1633            }
1634        };
1635        stream.write_all(request_line).await.unwrap();
1636    }
1637
1638    fn reserve_free_port() -> u16 {
1639        std::net::TcpListener::bind("127.0.0.1:0")
1640            .unwrap()
1641            .local_addr()
1642            .unwrap()
1643            .port()
1644    }
1645
1646    /// True if `err` is `bind_callback_listener`'s wrapped `AddrInUse` —
1647    /// i.e. some other process/test won the race for the port reserved by
1648    /// [`reserve_free_port`] before `login_to` could rebind it.
1649    fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1650        err.to_string()
1651            .contains("Failed to start the local OAuth callback listener")
1652    }
1653
1654    const PORT_RETRY_ATTEMPTS: u32 = 5;
1655
1656    /// Runs `attempt`, which reserves its own port via [`reserve_free_port`]
1657    /// and returns `login_to`'s result, retrying up to
1658    /// [`PORT_RETRY_ATTEMPTS`] times when the attempt loses the ephemeral
1659    /// port race (see the module comment above `connect_and_send`).
1660    async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<DriveAuthStatus>
1661    where
1662        F: FnMut(u16) -> Fut,
1663        Fut: std::future::Future<Output = Result<DriveAuthStatus>>,
1664    {
1665        for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1666            let result = attempt(reserve_free_port()).await;
1667            let is_retryable_conflict =
1668                matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1669            if !is_retryable_conflict {
1670                return result;
1671            }
1672        }
1673        unreachable!("loop always returns on its last iteration")
1674    }
1675
1676    /// Awaits `connector` normally, unless `result` shows `login_to` lost
1677    /// the callback-port race — in which case the connector, which will
1678    /// never see a connection on the now-taken port, is aborted instead of
1679    /// hung.
1680    async fn finish_connector(
1681        connector: tokio::task::JoinHandle<()>,
1682        result: &Result<DriveAuthStatus>,
1683    ) {
1684        match result {
1685            Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1686            _ => connector.await.unwrap(),
1687        }
1688    }
1689
1690    /// Polls `path` until it holds non-empty content, then returns it —
1691    /// used to read back the authorization URL that `open_browser`'s
1692    /// captured shell command writes asynchronously.
1693    async fn wait_for_captured_url(path: &Path) -> String {
1694        loop {
1695            if let Ok(contents) = std::fs::read_to_string(path) {
1696                if !contents.is_empty() {
1697                    return contents;
1698                }
1699            }
1700            tokio::time::sleep(Duration::from_millis(2)).await;
1701        }
1702    }
1703
1704    /// Shared body for the three `login_to_*` tests that drive a single
1705    /// fixed callback request line through `login_to` and expect it to
1706    /// error: reserves a port, spawns the connector, calls `login_to`, and
1707    /// retries the whole attempt (via [`run_with_port_retry`]) if it loses
1708    /// the ephemeral-port race. Asserts no settings file was written and
1709    /// returns the resulting error for the caller to inspect.
1710    async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
1711        std::fs::create_dir_all("tmp").ok();
1712        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1713        let settings_path = temp_dir.path().join("settings.json");
1714
1715        let result = run_with_port_retry(|port| {
1716            let settings_path = settings_path.clone();
1717            async move {
1718                let browser = BrowserConfig {
1719                    launch: BrowserLaunch::Manual,
1720                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1721                    callback_port: port,
1722                };
1723                let connector = tokio::spawn(connect_and_send(port, request_line));
1724
1725                let result = login_to(
1726                    &settings_path,
1727                    None,
1728                    "client-id",
1729                    &Secret::new("client-secret"),
1730                    DriveScope::ReadOnly,
1731                    &browser,
1732                    "http://127.0.0.1:1/token", // never reached — fails before exchange
1733                )
1734                .await;
1735
1736                finish_connector(connector, &result).await;
1737                result
1738            }
1739        })
1740        .await;
1741
1742        let err = result.unwrap_err();
1743        assert!(!settings_path.exists());
1744        err
1745    }
1746
1747    #[tokio::test]
1748    async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
1749        let attempts = AtomicU32::new(0);
1750
1751        let status = run_with_port_retry(|_port| {
1752            let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
1753            async move {
1754                if attempt_no == 0 {
1755                    Err(anyhow::anyhow!(
1756                        "Failed to start the local OAuth callback listener: address in use"
1757                    ))
1758                } else {
1759                    Ok(DriveAuthStatus {
1760                        has_client_id: true,
1761                        has_client_secret: true,
1762                        has_refresh_token: true,
1763                        scope: None,
1764                    })
1765                }
1766            }
1767        })
1768        .await
1769        .unwrap();
1770
1771        assert_eq!(attempts.load(Ordering::SeqCst), 2);
1772        assert!(status.has_client_id);
1773    }
1774
1775    #[tokio::test]
1776    async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
1777        let attempts = AtomicU32::new(0);
1778
1779        let result = run_with_port_retry(|_port| {
1780            attempts.fetch_add(1, Ordering::SeqCst);
1781            async move { Err(anyhow::anyhow!("some other failure")) }
1782        })
1783        .await;
1784
1785        assert_eq!(attempts.load(Ordering::SeqCst), 1);
1786        assert!(result.is_err());
1787    }
1788
1789    #[tokio::test]
1790    async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
1791        let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
1792        let connector = tokio::spawn(async move {
1793            tokio::time::sleep(Duration::from_secs(3600)).await;
1794            let _ = tx.send(());
1795        });
1796        let result: Result<DriveAuthStatus> = Err(anyhow::anyhow!(
1797            "Failed to start the local OAuth callback listener: address in use"
1798        ));
1799
1800        tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
1801            .await
1802            .expect("finish_connector must not wait for an aborted connector");
1803
1804        assert!(
1805            rx.try_recv().is_err(),
1806            "connector must have been aborted, not run to completion"
1807        );
1808    }
1809
1810    #[tokio::test]
1811    async fn finish_connector_awaits_connector_when_login_to_succeeds() {
1812        let ran = Arc::new(AtomicBool::new(false));
1813        let ran_clone = ran.clone();
1814        let connector = tokio::spawn(async move {
1815            ran_clone.store(true, Ordering::SeqCst);
1816        });
1817        let result = Ok(DriveAuthStatus {
1818            has_client_id: true,
1819            has_client_secret: true,
1820            has_refresh_token: true,
1821            scope: None,
1822        });
1823
1824        finish_connector(connector, &result).await;
1825
1826        assert!(ran.load(Ordering::SeqCst));
1827    }
1828
1829    #[tokio::test]
1830    async fn wait_for_captured_url_polls_until_content_is_written() {
1831        std::fs::create_dir_all("tmp").ok();
1832        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1833        let path = temp_dir.path().join("captured-url.txt");
1834
1835        let write_path = path.clone();
1836        tokio::spawn(async move {
1837            tokio::time::sleep(Duration::from_millis(10)).await;
1838            std::fs::write(&write_path, "").unwrap();
1839            tokio::time::sleep(Duration::from_millis(10)).await;
1840            std::fs::write(&write_path, "https://example.com/authorize").unwrap();
1841        });
1842
1843        let contents = wait_for_captured_url(&path).await;
1844        assert_eq!(contents, "https://example.com/authorize");
1845    }
1846
1847    #[tokio::test]
1848    async fn login_to_rejects_a_callback_with_mismatched_state() {
1849        let err =
1850            run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
1851
1852        assert!(matches!(
1853            err.downcast_ref::<DriveError>(),
1854            Some(DriveError::StateMismatch)
1855        ));
1856    }
1857
1858    #[tokio::test]
1859    async fn login_to_surfaces_access_denied_from_the_callback() {
1860        let err = run_login_to_expect_err(
1861            b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
1862        )
1863        .await;
1864
1865        match err.downcast_ref::<DriveError>() {
1866            Some(DriveError::AuthorizationDenied(message)) => {
1867                assert!(message.contains("access_denied"));
1868                assert!(message.contains("user declined"));
1869            }
1870            other => panic!("expected AuthorizationDenied, got {other:?}"),
1871        }
1872    }
1873
1874    #[tokio::test]
1875    async fn login_to_rejects_a_callback_missing_code_and_state() {
1876        let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
1877
1878        assert!(matches!(
1879            err.downcast_ref::<DriveError>(),
1880            Some(DriveError::MalformedCallback)
1881        ));
1882    }
1883
1884    #[tokio::test]
1885    async fn login_to_completes_full_success_flow_and_persists_credentials() {
1886        // Captures the real authorization URL `login_to` generates (with its
1887        // randomly-generated CSRF `state`) by pointing the browser launch at
1888        // a shell command instead of an actual browser: `open_browser`
1889        // substitutes `{url}` into the command's args and spawns it, so a
1890        // tiny `/bin/sh` one-liner writes the URL to a file we can read back
1891        // — letting this test drive the full success path (state echoed
1892        // correctly, token exchange, credential persistence) without ever
1893        // opening a real browser or needing to predict the CSRF nonce.
1894        std::fs::create_dir_all("tmp").ok();
1895        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1896        let capture_path = temp_dir.path().join("captured-url.txt");
1897        let settings_path = temp_dir.path().join("settings.json");
1898
1899        let server = wiremock::MockServer::start().await;
1900        wiremock::Mock::given(wiremock::matchers::method("POST"))
1901            .and(wiremock::matchers::path("/token"))
1902            .respond_with(
1903                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1904                    "access_token": "at-1",
1905                    "refresh_token": "rt-1",
1906                    "expires_in": 3600,
1907                    "scope": SCOPE_READONLY,
1908                })),
1909            )
1910            .expect(1)
1911            .mount(&server)
1912            .await;
1913
1914        let status = run_with_port_retry(|port| {
1915            let capture_path = capture_path.clone();
1916            let settings_path = settings_path.clone();
1917            let token_endpoint = format!("{}/token", server.uri());
1918            async move {
1919                let browser = BrowserConfig {
1920                    launch: BrowserLaunch::Command(vec![
1921                        "/bin/sh".to_string(),
1922                        "-c".to_string(),
1923                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
1924                        "{url}".to_string(),
1925                    ]),
1926                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1927                    callback_port: port,
1928                };
1929
1930                let connector = tokio::spawn(async move {
1931                    let auth_url = wait_for_captured_url(&capture_path).await;
1932                    let parsed = Url::parse(&auth_url).unwrap();
1933                    let state = parsed
1934                        .query_pairs()
1935                        .find(|(k, _)| k == "state")
1936                        .map(|(_, v)| v.into_owned())
1937                        .expect("authorization URL must carry a state param");
1938                    connect_and_send(
1939                        port,
1940                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
1941                    )
1942                    .await;
1943                });
1944
1945                let result = login_to(
1946                    &settings_path,
1947                    None,
1948                    "client-id",
1949                    &Secret::new("client-secret"),
1950                    DriveScope::ReadOnly,
1951                    &browser,
1952                    &token_endpoint,
1953                )
1954                .await;
1955
1956                finish_connector(connector, &result).await;
1957                result
1958            }
1959        })
1960        .await
1961        .unwrap();
1962
1963        assert!(status.has_client_id);
1964        assert!(status.has_client_secret);
1965        assert!(status.has_refresh_token);
1966        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
1967
1968        let saved = std::fs::read_to_string(&settings_path).unwrap();
1969        assert!(saved.contains("rt-1"));
1970        assert!(saved.contains("client-id"));
1971    }
1972
1973    /// Full mocked login round trip (real state nonce echoed back via the
1974    /// captured-authorization-URL trick, like
1975    /// `login_to_completes_full_success_flow_and_persists_credentials`
1976    /// above), with an injectable token-response body — the seam the
1977    /// scope-validation tests below use to simulate Google granting no
1978    /// Drive scope.
1979    async fn run_login_to_with_token_response(
1980        token_response_body: serde_json::Value,
1981    ) -> (Result<DriveAuthStatus>, std::path::PathBuf) {
1982        std::fs::create_dir_all("tmp").ok();
1983        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1984        let capture_path = temp_dir.path().join("captured-url.txt");
1985        let settings_path = temp_dir.path().join("settings.json");
1986
1987        let server = wiremock::MockServer::start().await;
1988        wiremock::Mock::given(wiremock::matchers::method("POST"))
1989            .and(wiremock::matchers::path("/token"))
1990            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
1991            .expect(1)
1992            .mount(&server)
1993            .await;
1994
1995        let result = run_with_port_retry(|port| {
1996            let capture_path = capture_path.clone();
1997            let settings_path = settings_path.clone();
1998            let token_endpoint = format!("{}/token", server.uri());
1999            async move {
2000                let browser = BrowserConfig {
2001                    launch: BrowserLaunch::Command(vec![
2002                        "/bin/sh".to_string(),
2003                        "-c".to_string(),
2004                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2005                        "{url}".to_string(),
2006                    ]),
2007                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2008                    callback_port: port,
2009                };
2010
2011                let connector = tokio::spawn(async move {
2012                    let auth_url = wait_for_captured_url(&capture_path).await;
2013                    let parsed = Url::parse(&auth_url).unwrap();
2014                    let state = parsed
2015                        .query_pairs()
2016                        .find(|(k, _)| k == "state")
2017                        .map(|(_, v)| v.into_owned())
2018                        .expect("authorization URL must carry a state param");
2019                    connect_and_send(
2020                        port,
2021                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2022                    )
2023                    .await;
2024                });
2025
2026                let result = login_to(
2027                    &settings_path,
2028                    None,
2029                    "client-id",
2030                    &Secret::new("client-secret"),
2031                    DriveScope::ReadOnly,
2032                    &browser,
2033                    &token_endpoint,
2034                )
2035                .await;
2036
2037                finish_connector(connector, &result).await;
2038                result
2039            }
2040        })
2041        .await;
2042
2043        (result, settings_path)
2044    }
2045
2046    #[tokio::test]
2047    async fn login_to_rejects_a_grant_with_no_drive_scope() {
2048        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2049            "access_token": "at-1",
2050            "refresh_token": "rt-1",
2051            "expires_in": 3600,
2052            "scope": "openid email profile",
2053        }))
2054        .await;
2055
2056        let err = result.unwrap_err();
2057        assert!(matches!(
2058            err.downcast_ref::<DriveError>(),
2059            Some(DriveError::NoScopeGranted(received)) if received == "openid, email, profile"
2060        ));
2061        assert!(!settings_path.exists());
2062    }
2063
2064    #[tokio::test]
2065    async fn login_to_rejects_a_grant_with_missing_scope_field() {
2066        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2067            "access_token": "at-1",
2068            "refresh_token": "rt-1",
2069            "expires_in": 3600,
2070        }))
2071        .await;
2072
2073        let err = result.unwrap_err();
2074        assert!(matches!(
2075            err.downcast_ref::<DriveError>(),
2076            Some(DriveError::NoScopeGranted(received)) if received == "none"
2077        ));
2078        assert!(!settings_path.exists());
2079    }
2080
2081    // ── login_for (named-account login orchestration, mirrors Gmail's
2082    // issue #1500) ───────────────────────────────────────────────────────
2083    //
2084    // `login_for` hardcodes the real `TOKEN_ENDPOINT` in both branches
2085    // (unlike `login_to`, which takes one as an explicit test seam), so a
2086    // full success round trip can't be driven against a wiremock server
2087    // here. These instead drive a callback with a mismatched `state` —
2088    // which `run_login_flow` rejects *before* ever reaching the token
2089    // endpoint — to exercise account resolution (`Settings::load` +
2090    // `resolve_for_write`) and, for the named branch, the `run_login_flow`
2091    // call site itself, without any real network call.
2092
2093    #[tokio::test]
2094    async fn login_for_unconfigured_account_rejects_a_callback_with_mismatched_state() {
2095        let guard = crate::drive::test_support::EnvGuard::take();
2096        let dir = guard.clear_credentials();
2097        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2098
2099        let result = run_with_port_retry(|port| async move {
2100            let browser = BrowserConfig {
2101                launch: BrowserLaunch::Manual,
2102                callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2103                callback_port: port,
2104            };
2105            let connector = tokio::spawn(connect_and_send(
2106                port,
2107                b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2108            ));
2109
2110            let result = login_for(
2111                None,
2112                "client-id",
2113                &Secret::new("client-secret"),
2114                DriveScope::ReadOnly,
2115                &browser,
2116            )
2117            .await;
2118
2119            finish_connector(connector, &result).await;
2120            result
2121        })
2122        .await;
2123
2124        let err = result.unwrap_err();
2125        assert!(matches!(
2126            err.downcast_ref::<DriveError>(),
2127            Some(DriveError::StateMismatch)
2128        ));
2129        assert!(!settings_path.exists());
2130    }
2131
2132    #[tokio::test]
2133    async fn login_for_named_account_rejects_a_callback_with_mismatched_state() {
2134        let guard = crate::drive::test_support::EnvGuard::take();
2135        let dir = guard.clear_credentials();
2136        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2137
2138        let result = run_with_port_retry(|port| async move {
2139            let browser = BrowserConfig {
2140                launch: BrowserLaunch::Manual,
2141                callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2142                callback_port: port,
2143            };
2144            let connector = tokio::spawn(connect_and_send(
2145                port,
2146                b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2147            ));
2148
2149            let result = login_for(
2150                Some("work"),
2151                "client-id",
2152                &Secret::new("client-secret"),
2153                DriveScope::ReadOnly,
2154                &browser,
2155            )
2156            .await;
2157
2158            finish_connector(connector, &result).await;
2159            result
2160        })
2161        .await;
2162
2163        let err = result.unwrap_err();
2164        assert!(matches!(
2165            err.downcast_ref::<DriveError>(),
2166            Some(DriveError::StateMismatch)
2167        ));
2168        assert!(!settings_path.exists());
2169    }
2170
2171    #[tokio::test]
2172    async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2173        let server = wiremock::MockServer::start().await;
2174        wiremock::Mock::given(wiremock::matchers::method("POST"))
2175            .and(wiremock::matchers::body_string_contains(
2176                "grant_type=refresh_token",
2177            ))
2178            .respond_with(
2179                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2180                    "access_token": "at-2",
2181                    "expires_in": 1800,
2182                })),
2183            )
2184            .expect(1)
2185            .mount(&server)
2186            .await;
2187
2188        let http = reqwest::Client::new();
2189        let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2190            .await
2191            .unwrap();
2192        assert_eq!(response.access_token, "at-2");
2193        assert_eq!(response.expires_in, 1800);
2194    }
2195
2196    #[tokio::test]
2197    async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2198        let server = wiremock::MockServer::start().await;
2199        wiremock::Mock::given(wiremock::matchers::method("POST"))
2200            .respond_with(
2201                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2202                    "error": "invalid_grant",
2203                })),
2204            )
2205            .mount(&server)
2206            .await;
2207
2208        let http = reqwest::Client::new();
2209        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2210            .await
2211            .unwrap_err();
2212        let msg = err.to_string();
2213        assert!(msg.contains("7 days"));
2214        assert!(msg.contains("Testing"));
2215    }
2216
2217    #[tokio::test]
2218    async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2219        let server = wiremock::MockServer::start().await;
2220        wiremock::Mock::given(wiremock::matchers::method("POST"))
2221            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2222            .mount(&server)
2223            .await;
2224
2225        let http = reqwest::Client::new();
2226        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2227            .await
2228            .unwrap_err();
2229        let msg = err.to_string();
2230        assert!(msg.contains("unparsable error body"));
2231        assert!(msg.contains("not json"));
2232    }
2233
2234    #[tokio::test]
2235    async fn token_request_propagates_network_errors() {
2236        let http = reqwest::Client::new();
2237        let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2238            .await
2239            .unwrap_err();
2240        assert!(err.to_string().contains("Failed to send token request"));
2241    }
2242
2243    #[tokio::test]
2244    async fn token_request_errors_on_unparsable_response_body() {
2245        let server = wiremock::MockServer::start().await;
2246        wiremock::Mock::given(wiremock::matchers::method("POST"))
2247            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2248            .mount(&server)
2249            .await;
2250
2251        let http = reqwest::Client::new();
2252        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2253            .await
2254            .unwrap_err();
2255        assert!(err.to_string().contains("Failed to parse"));
2256    }
2257
2258    // ── DriveSession ─────────────────────────────────────────────────
2259
2260    fn test_credentials() -> DriveCredentials {
2261        DriveCredentials {
2262            client_id: "client-1".to_string(),
2263            client_secret: "secret-1".into(),
2264            refresh_token: "refresh-1".into(),
2265            scope: DriveScope::ReadOnly,
2266        }
2267    }
2268
2269    #[tokio::test]
2270    async fn access_token_refreshes_on_first_call() {
2271        let server = wiremock::MockServer::start().await;
2272        wiremock::Mock::given(wiremock::matchers::method("POST"))
2273            .respond_with(
2274                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2275                    "access_token": "at-first",
2276                    "expires_in": 3600,
2277                })),
2278            )
2279            .expect(1)
2280            .mount(&server)
2281            .await;
2282
2283        let session = DriveSession::new_with_token_endpoint(
2284            reqwest::Client::new(),
2285            &test_credentials(),
2286            &server.uri(),
2287        );
2288        let token = session.access_token().await.unwrap();
2289        assert_eq!(token.expose_secret(), "at-first");
2290    }
2291
2292    #[tokio::test]
2293    async fn access_token_reuses_cached_token_within_skew_window() {
2294        let server = wiremock::MockServer::start().await;
2295        wiremock::Mock::given(wiremock::matchers::method("POST"))
2296            .respond_with(
2297                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2298                    "access_token": "at-cached",
2299                    "expires_in": 3600,
2300                })),
2301            )
2302            .expect(1)
2303            .mount(&server)
2304            .await;
2305
2306        let session = DriveSession::new_with_token_endpoint(
2307            reqwest::Client::new(),
2308            &test_credentials(),
2309            &server.uri(),
2310        );
2311        let first = session.access_token().await.unwrap();
2312        let second = session.access_token().await.unwrap();
2313        assert_eq!(first, second);
2314    }
2315
2316    #[tokio::test]
2317    async fn access_token_proactively_refreshes_when_within_skew_window() {
2318        let server = wiremock::MockServer::start().await;
2319        wiremock::Mock::given(wiremock::matchers::method("POST"))
2320            .respond_with(
2321                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2322                    "access_token": "at-short",
2323                    // Less than REFRESH_SKEW (60s), so the second call also refreshes.
2324                    "expires_in": 30,
2325                })),
2326            )
2327            .expect(2)
2328            .mount(&server)
2329            .await;
2330
2331        let session = DriveSession::new_with_token_endpoint(
2332            reqwest::Client::new(),
2333            &test_credentials(),
2334            &server.uri(),
2335        );
2336        session.access_token().await.unwrap();
2337        session.access_token().await.unwrap();
2338    }
2339
2340    #[tokio::test]
2341    async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2342        let server = wiremock::MockServer::start().await;
2343        wiremock::Mock::given(wiremock::matchers::method("POST"))
2344            .respond_with(
2345                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2346                    "access_token": "at-overflow",
2347                    // Unvalidated, this overflows both TimeDelta::seconds and
2348                    // the subsequent DateTime<Utc> addition (#1531).
2349                    "expires_in": i64::MAX,
2350                })),
2351            )
2352            .expect(1)
2353            .mount(&server)
2354            .await;
2355
2356        let session = DriveSession::new_with_token_endpoint(
2357            reqwest::Client::new(),
2358            &test_credentials(),
2359            &server.uri(),
2360        );
2361        let token = session.access_token().await.unwrap();
2362        assert_eq!(token.expose_secret(), "at-overflow");
2363    }
2364
2365    #[tokio::test]
2366    async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2367        let server = wiremock::MockServer::start().await;
2368        wiremock::Mock::given(wiremock::matchers::method("POST"))
2369            .respond_with(
2370                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2371                    "access_token": "at-negative",
2372                    "expires_in": -3600,
2373                })),
2374            )
2375            // Negative expires_in clamps to 0, so the token is already
2376            // stale and the second call refreshes again.
2377            .expect(2)
2378            .mount(&server)
2379            .await;
2380
2381        let session = DriveSession::new_with_token_endpoint(
2382            reqwest::Client::new(),
2383            &test_credentials(),
2384            &server.uri(),
2385        );
2386        session.access_token().await.unwrap();
2387        session.access_token().await.unwrap();
2388    }
2389
2390    #[tokio::test]
2391    async fn force_refresh_concurrent_callers_do_not_stampede() {
2392        let server = wiremock::MockServer::start().await;
2393        wiremock::Mock::given(wiremock::matchers::method("POST"))
2394            .respond_with(
2395                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2396                    "access_token": "at-bootstrap",
2397                    "expires_in": 3600,
2398                })),
2399            )
2400            .up_to_n_times(1)
2401            .with_priority(1)
2402            .mount(&server)
2403            .await;
2404        wiremock::Mock::given(wiremock::matchers::method("POST"))
2405            .respond_with(
2406                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2407                    "access_token": "at-refreshed",
2408                    "expires_in": 3600,
2409                })),
2410            )
2411            .expect(1)
2412            .with_priority(2)
2413            .mount(&server)
2414            .await;
2415
2416        let session = DriveSession::new_with_token_endpoint(
2417            reqwest::Client::new(),
2418            &test_credentials(),
2419            &server.uri(),
2420        );
2421        let bootstrapped = session.access_token().await.unwrap();
2422        assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2423
2424        let (a, b) = tokio::join!(
2425            session.force_refresh(&bootstrapped),
2426            session.force_refresh(&bootstrapped)
2427        );
2428        let a = a.unwrap();
2429        let b = b.unwrap();
2430        assert_eq!(a, b);
2431        assert_eq!(a.expose_secret(), "at-refreshed");
2432    }
2433
2434    #[tokio::test]
2435    async fn force_refresh_skips_network_call_when_token_already_rotated() {
2436        let server = wiremock::MockServer::start().await;
2437        wiremock::Mock::given(wiremock::matchers::method("POST"))
2438            .respond_with(
2439                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2440                    "access_token": "at-a",
2441                    "expires_in": 3600,
2442                })),
2443            )
2444            .up_to_n_times(1)
2445            .with_priority(1)
2446            .mount(&server)
2447            .await;
2448        wiremock::Mock::given(wiremock::matchers::method("POST"))
2449            .respond_with(
2450                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2451                    "access_token": "at-b",
2452                    "expires_in": 3600,
2453                })),
2454            )
2455            .expect(1)
2456            .with_priority(2)
2457            .mount(&server)
2458            .await;
2459
2460        let session = DriveSession::new_with_token_endpoint(
2461            reqwest::Client::new(),
2462            &test_credentials(),
2463            &server.uri(),
2464        );
2465        let stale = Secret::new("at-never-issued");
2466        let bootstrapped = session.access_token().await.unwrap();
2467        assert_eq!(bootstrapped.expose_secret(), "at-a");
2468
2469        // `stale` was never the live token, so this should reuse the current
2470        // one without an extra network call.
2471        let result = session.force_refresh(&stale).await.unwrap();
2472        assert_eq!(result, bootstrapped);
2473
2474        // A real force_refresh against the actual current token does POST.
2475        let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2476        assert_eq!(refreshed.expose_secret(), "at-b");
2477    }
2478
2479    // ── Secret non-leakage ───────────────────────────────────────────
2480
2481    #[test]
2482    fn drive_credentials_debug_redacts_client_secret_and_refresh_token() {
2483        let creds = DriveCredentials {
2484            client_id: "client-visible".to_string(),
2485            client_secret: "sekret-client-secret".into(),
2486            refresh_token: "sekret-refresh-token".into(),
2487            scope: DriveScope::ReadOnly,
2488        };
2489        let debug = format!("{creds:?}");
2490        assert!(debug.contains("DriveCredentials"));
2491        assert!(debug.contains("client-visible"));
2492        assert!(!debug.contains("sekret-client-secret"));
2493        assert!(!debug.contains("sekret-refresh-token"));
2494        assert!(debug.contains("client_secret: <redacted>"));
2495        assert!(debug.contains("refresh_token: <redacted>"));
2496    }
2497
2498    #[test]
2499    fn drive_auth_status_yaml_serialization_contains_no_secret_values() {
2500        let env = crate::test_support::env::MapEnv::new()
2501            .with(DRIVE_CLIENT_ID, "client-id-value")
2502            .with(DRIVE_CLIENT_SECRET, "sekret-do-not-leak")
2503            .with(DRIVE_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2504            .with(DRIVE_SCOPE, SCOPE_READONLY);
2505        let status = status_with(&env);
2506        let yaml = serde_yaml::to_string(&status).unwrap();
2507        assert!(!yaml.contains("sekret-do-not-leak"));
2508        assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2509    }
2510
2511    // ── Env-DI boundary tests ────────────────────────────────────────
2512
2513    use crate::test_support::env::MapEnv;
2514
2515    #[test]
2516    fn status_reports_all_false_when_nothing_configured() {
2517        let status = status_with(&MapEnv::new());
2518        assert!(!status.has_client_id);
2519        assert!(!status.has_client_secret);
2520        assert!(!status.has_refresh_token);
2521        assert_eq!(status.scope, None);
2522    }
2523
2524    #[test]
2525    fn status_reports_scope_when_present() {
2526        let env = MapEnv::new().with(DRIVE_SCOPE, SCOPE_READONLY);
2527        let status = status_with(&env);
2528        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2529    }
2530
2531    #[test]
2532    fn load_credentials_errors_when_client_id_missing() {
2533        let env = MapEnv::new()
2534            .with(DRIVE_CLIENT_SECRET, "s")
2535            .with(DRIVE_REFRESH_TOKEN, "r");
2536        let err = load_credentials_with(&env).unwrap_err();
2537        assert!(err.to_string().contains("not configured"));
2538    }
2539
2540    #[test]
2541    fn load_credentials_errors_when_client_secret_missing() {
2542        let env = MapEnv::new()
2543            .with(DRIVE_CLIENT_ID, "c")
2544            .with(DRIVE_REFRESH_TOKEN, "r");
2545        assert!(load_credentials_with(&env).is_err());
2546    }
2547
2548    #[test]
2549    fn load_credentials_errors_when_refresh_token_missing() {
2550        let env = MapEnv::new()
2551            .with(DRIVE_CLIENT_ID, "c")
2552            .with(DRIVE_CLIENT_SECRET, "s");
2553        assert!(load_credentials_with(&env).is_err());
2554    }
2555
2556    #[test]
2557    fn load_credentials_succeeds_with_all_three_present() {
2558        let env = MapEnv::new()
2559            .with(DRIVE_CLIENT_ID, "c")
2560            .with(DRIVE_CLIENT_SECRET, "s")
2561            .with(DRIVE_REFRESH_TOKEN, "r");
2562        let creds = load_credentials_with(&env).unwrap();
2563        assert_eq!(creds.client_id, "c");
2564        assert_eq!(creds.scope, DriveScope::ReadOnly);
2565    }
2566
2567    /// Save + remove round-trip against injected settings-file paths — no
2568    /// `HOME` mutation, so the test needs no lock.
2569    #[test]
2570    fn save_then_remove_round_trip() {
2571        // ── Part 1: creates file from scratch ──────────────────────
2572        {
2573            let temp_dir = {
2574                std::fs::create_dir_all("tmp").ok();
2575                tempfile::TempDir::new_in("tmp").unwrap()
2576            };
2577            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2578
2579            let creds = DriveCredentials {
2580                client_id: "client-1".to_string(),
2581                client_secret: "secret-1".into(),
2582                refresh_token: "refresh-1".into(),
2583                scope: DriveScope::ReadOnly,
2584            };
2585            save_credentials_to(&settings_path, None, &creds).unwrap();
2586
2587            assert!(settings_path.exists());
2588            let val: serde_json::Value =
2589                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2590            assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "client-1");
2591            assert_eq!(val["env"]["DRIVE_CLIENT_SECRET"], "secret-1");
2592            assert_eq!(val["env"]["DRIVE_REFRESH_TOKEN"], "refresh-1");
2593            assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2594
2595            #[cfg(unix)]
2596            {
2597                use std::os::unix::fs::PermissionsExt;
2598                let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2599                assert_eq!(mode & 0o777, 0o600);
2600            }
2601        }
2602
2603        // ── Part 2: merges into existing settings ──────────────────
2604        {
2605            let temp_dir = {
2606                std::fs::create_dir_all("tmp").ok();
2607                tempfile::TempDir::new_in("tmp").unwrap()
2608            };
2609            let omni_dir = temp_dir.path().join(".omni-dev");
2610            fs::create_dir_all(&omni_dir).unwrap();
2611            let settings_path = omni_dir.join("settings.json");
2612            fs::write(
2613                &settings_path,
2614                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2615            )
2616            .unwrap();
2617
2618            let creds = DriveCredentials {
2619                client_id: "client-2".to_string(),
2620                client_secret: "secret-2".into(),
2621                refresh_token: "refresh-2".into(),
2622                scope: DriveScope::ReadOnly,
2623            };
2624            save_credentials_to(&settings_path, None, &creds).unwrap();
2625
2626            let val: serde_json::Value =
2627                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2628            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2629            assert_eq!(val["extra"], true);
2630            assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2631        }
2632
2633        // ── Part 3: remove clears the four keys, preserves others ──
2634        {
2635            let temp_dir = {
2636                std::fs::create_dir_all("tmp").ok();
2637                tempfile::TempDir::new_in("tmp").unwrap()
2638            };
2639            let omni_dir = temp_dir.path().join(".omni-dev");
2640            fs::create_dir_all(&omni_dir).unwrap();
2641            let settings_path = omni_dir.join("settings.json");
2642            fs::write(
2643                &settings_path,
2644                r#"{"env": {
2645                    "DRIVE_CLIENT_ID": "a",
2646                    "DRIVE_CLIENT_SECRET": "b",
2647                    "DRIVE_REFRESH_TOKEN": "c",
2648                    "DRIVE_SCOPE": "d",
2649                    "OTHER_KEY": "keep"
2650                }}"#,
2651            )
2652            .unwrap();
2653
2654            let removed = remove_credentials_at(&settings_path, None).unwrap();
2655            assert!(removed);
2656
2657            let val: serde_json::Value =
2658                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2659            assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
2660            assert!(val["env"].get("DRIVE_CLIENT_SECRET").is_none());
2661            assert!(val["env"].get("DRIVE_REFRESH_TOKEN").is_none());
2662            assert!(val["env"].get("DRIVE_SCOPE").is_none());
2663            assert_eq!(val["env"]["OTHER_KEY"], "keep");
2664        }
2665
2666        // ── Part 4: remove returns false when nothing to remove ────
2667        {
2668            let temp_dir = {
2669                std::fs::create_dir_all("tmp").ok();
2670                tempfile::TempDir::new_in("tmp").unwrap()
2671            };
2672            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2673            let removed = remove_credentials_at(&settings_path, None).unwrap();
2674            assert!(!removed);
2675        }
2676    }
2677
2678    /// Save + remove round-trip against a profile-targeted env map.
2679    #[test]
2680    fn save_then_remove_round_trip_in_profile() {
2681        let temp_dir = {
2682            std::fs::create_dir_all("tmp").ok();
2683            tempfile::TempDir::new_in("tmp").unwrap()
2684        };
2685        let omni_dir = temp_dir.path().join(".omni-dev");
2686        fs::create_dir_all(&omni_dir).unwrap();
2687        let settings_path = omni_dir.join("settings.json");
2688        fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
2689
2690        let creds = DriveCredentials {
2691            client_id: "client-p".to_string(),
2692            client_secret: "secret-p".into(),
2693            refresh_token: "refresh-p".into(),
2694            scope: DriveScope::ReadOnly,
2695        };
2696        save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
2697
2698        let val: serde_json::Value =
2699            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2700        assert_eq!(
2701            val["profiles"]["work"]["env"]["DRIVE_CLIENT_ID"],
2702            "client-p"
2703        );
2704        assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
2705        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2706
2707        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2708        assert!(removed);
2709        let val: serde_json::Value =
2710            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2711        assert!(val["profiles"]["work"]["env"]
2712            .get("DRIVE_CLIENT_ID")
2713            .is_none());
2714
2715        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2716        assert!(!removed);
2717    }
2718
2719    /// The production wrappers resolve `~/.omni-dev/settings.json` from
2720    /// `HOME` and the active profile from `OMNI_DEV_PROFILE`, so this one
2721    /// test must redirect both via [`crate::drive::test_support::EnvGuard`].
2722    #[test]
2723    fn save_and_remove_credentials_resolve_default_settings_path() {
2724        let guard = crate::drive::test_support::EnvGuard::take();
2725        let dir = guard.clear_credentials();
2726
2727        let creds = DriveCredentials {
2728            client_id: "wrapper-client".to_string(),
2729            client_secret: "wrapper-secret".into(),
2730            refresh_token: "wrapper-refresh".into(),
2731            scope: DriveScope::ReadOnly,
2732        };
2733        save_credentials(&creds).unwrap();
2734
2735        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2736        let val: serde_json::Value =
2737            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2738        assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "wrapper-client");
2739
2740        assert!(remove_credentials().unwrap());
2741        let val: serde_json::Value =
2742            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2743        assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
2744    }
2745
2746    // ── named-account dispatch (mirrors Gmail's issue #1500) ────────────
2747    //
2748    // These exercise the production `*_for` wrappers, so — like
2749    // `save_and_remove_credentials_resolve_default_settings_path` above —
2750    // they must redirect `HOME` via `EnvGuard`.
2751
2752    #[test]
2753    fn load_credentials_for_named_reads_from_drive_accounts() {
2754        let guard = crate::drive::test_support::EnvGuard::take();
2755        let dir = guard.clear_credentials();
2756        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2757        Settings::upsert_drive_account(
2758            &settings_path,
2759            "work",
2760            &[
2761                (
2762                    "client_id",
2763                    serde_json::Value::String("work-id".to_string()),
2764                ),
2765                (
2766                    "client_secret",
2767                    serde_json::Value::String("work-secret".to_string()),
2768                ),
2769                (
2770                    "refresh_token",
2771                    serde_json::Value::String("work-refresh".to_string()),
2772                ),
2773                (
2774                    "scope",
2775                    serde_json::Value::String(SCOPE_READONLY.to_string()),
2776                ),
2777            ],
2778        )
2779        .unwrap();
2780
2781        let creds = load_credentials_for(Some("work")).unwrap();
2782        assert_eq!(creds.client_id, "work-id");
2783        assert_eq!(creds.client_secret.expose_secret(), "work-secret");
2784        assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
2785        assert_eq!(creds.scope, DriveScope::ReadOnly);
2786    }
2787
2788    #[test]
2789    fn load_credentials_for_unknown_named_account_errors() {
2790        let guard = crate::drive::test_support::EnvGuard::take();
2791        let dir = guard.clear_credentials();
2792        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2793        Settings::upsert_drive_account(
2794            &settings_path,
2795            "work",
2796            &[(
2797                "client_id",
2798                serde_json::Value::String("work-id".to_string()),
2799            )],
2800        )
2801        .unwrap();
2802
2803        let err = load_credentials_for(Some("bogus")).unwrap_err();
2804        assert!(err.to_string().contains("unknown Drive account 'bogus'"));
2805    }
2806
2807    #[test]
2808    fn load_credentials_for_falls_back_to_env_when_accounts_empty() {
2809        let guard = crate::drive::test_support::EnvGuard::take();
2810        let _dir = guard.clear_credentials();
2811        std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
2812        std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
2813        std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
2814
2815        let creds = load_credentials_for(None).unwrap();
2816        assert_eq!(creds.client_id, "literal-id");
2817    }
2818
2819    #[test]
2820    fn load_credentials_for_none_honors_ambient_account_env_var() {
2821        let guard = crate::drive::test_support::EnvGuard::take();
2822        let dir = guard.clear_credentials();
2823        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2824        Settings::upsert_drive_account(
2825            &settings_path,
2826            "work",
2827            &[
2828                (
2829                    "client_id",
2830                    serde_json::Value::String("work-id".to_string()),
2831                ),
2832                (
2833                    "client_secret",
2834                    serde_json::Value::String("work-secret".to_string()),
2835                ),
2836                (
2837                    "refresh_token",
2838                    serde_json::Value::String("work-refresh".to_string()),
2839                ),
2840            ],
2841        )
2842        .unwrap();
2843        std::env::set_var(account::DRIVE_ACCOUNT_ENV, "work");
2844
2845        let creds = load_credentials_for(None).unwrap();
2846        assert_eq!(creds.client_id, "work-id");
2847    }
2848
2849    #[test]
2850    fn remove_credentials_for_named_removes_whole_account() {
2851        let guard = crate::drive::test_support::EnvGuard::take();
2852        let dir = guard.clear_credentials();
2853        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2854        Settings::upsert_drive_account(
2855            &settings_path,
2856            "work",
2857            &[("client_id", serde_json::Value::String("id".to_string()))],
2858        )
2859        .unwrap();
2860
2861        assert!(remove_credentials_for(Some("work")).unwrap());
2862        let val: serde_json::Value =
2863            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2864        assert!(val["drive"]["accounts"].get("work").is_none());
2865    }
2866
2867    #[cfg(feature = "mcp")]
2868    #[test]
2869    fn status_for_named_reports_presence_from_account() {
2870        let guard = crate::drive::test_support::EnvGuard::take();
2871        let dir = guard.clear_credentials();
2872        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2873        Settings::upsert_drive_account(
2874            &settings_path,
2875            "work",
2876            &[
2877                ("client_id", serde_json::Value::String("id".to_string())),
2878                (
2879                    "scope",
2880                    serde_json::Value::String(SCOPE_READONLY.to_string()),
2881                ),
2882            ],
2883        )
2884        .unwrap();
2885
2886        let status = status_for(Some("work")).unwrap();
2887        assert!(status.has_client_id);
2888        assert!(!status.has_client_secret);
2889        assert!(!status.has_refresh_token);
2890        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2891    }
2892
2893    #[cfg(feature = "mcp")]
2894    #[test]
2895    fn status_for_unconfigured_matches_status_with_when_accounts_empty() {
2896        let guard = crate::drive::test_support::EnvGuard::take();
2897        let _dir = guard.clear_credentials();
2898        std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
2899
2900        let status = status_for(None).unwrap();
2901        assert!(status.has_client_id);
2902        assert!(!status.has_refresh_token);
2903    }
2904
2905    #[test]
2906    fn record_account_email_writes_email_address_only() {
2907        let guard = crate::drive::test_support::EnvGuard::take();
2908        let dir = guard.clear_credentials();
2909        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2910        Settings::upsert_drive_account(
2911            &settings_path,
2912            "work",
2913            &[("client_id", serde_json::Value::String("id".to_string()))],
2914        )
2915        .unwrap();
2916
2917        record_account_email("work", "alice@work.com").unwrap();
2918
2919        let val: serde_json::Value =
2920            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2921        assert_eq!(
2922            val["drive"]["accounts"]["work"]["email_address"],
2923            "alice@work.com"
2924        );
2925        assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2926    }
2927
2928    #[test]
2929    fn record_account_email_does_not_overwrite_an_existing_value() {
2930        let guard = crate::drive::test_support::EnvGuard::take();
2931        let dir = guard.clear_credentials();
2932        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2933        Settings::upsert_drive_account(
2934            &settings_path,
2935            "work",
2936            &[(
2937                "email_address",
2938                serde_json::Value::String("manually-set@work.com".to_string()),
2939            )],
2940        )
2941        .unwrap();
2942
2943        record_account_email("work", "alice@work.com").unwrap();
2944
2945        let val: serde_json::Value =
2946            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2947        assert_eq!(
2948            val["drive"]["accounts"]["work"]["email_address"],
2949            "manually-set@work.com"
2950        );
2951    }
2952
2953    /// The empty-`drive.accounts` fallback path (mirrors Gmail's issue
2954    /// #1500 zero-migration guarantee): with `drive.accounts` empty, the
2955    /// account-aware `_for(None, ...)` wrappers must behave byte-identically
2956    /// to the direct env/settings wrappers they sit beside. Both sandboxes
2957    /// are seeded via the same unchanged [`save_credentials`] (no
2958    /// account-aware save wrapper exists — `login_for` persists directly,
2959    /// since a resolve-then-save round trip would reject the very name
2960    /// being created), so this isolates `load`/`remove`.
2961    #[test]
2962    fn accounts_empty_load_remove_byte_identical_via_direct_and_for_wrappers() {
2963        let guard = crate::drive::test_support::EnvGuard::take();
2964        let creds = DriveCredentials {
2965            client_id: "id".to_string(),
2966            client_secret: "secret".into(),
2967            refresh_token: "refresh".into(),
2968            scope: DriveScope::ReadOnly,
2969        };
2970
2971        let dir_direct = guard.clear_credentials();
2972        save_credentials(&creds).unwrap();
2973        let direct_written =
2974            fs::read_to_string(dir_direct.path().join(".omni-dev").join("settings.json")).unwrap();
2975        let direct_loaded = load_credentials().unwrap();
2976        let direct_removed = remove_credentials().unwrap();
2977
2978        let dir_for = guard.clear_credentials();
2979        save_credentials(&creds).unwrap();
2980        let for_written =
2981            fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
2982        let for_loaded = load_credentials_for(None).unwrap();
2983        let for_removed = remove_credentials_for(None).unwrap();
2984
2985        assert_eq!(direct_written, for_written);
2986        assert_eq!(direct_loaded.client_id, for_loaded.client_id);
2987        assert_eq!(
2988            direct_loaded.client_secret.expose_secret(),
2989            for_loaded.client_secret.expose_secret()
2990        );
2991        assert_eq!(direct_loaded.scope, for_loaded.scope);
2992        assert_eq!(direct_removed, for_removed);
2993    }
2994}