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