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    // Refuse before the browser flow, not after: a token it then could not
1152    // save would be lost (ADR-0090).
1153    Settings::ensure_secrets_replaceable(
1154        settings_path,
1155        profile,
1156        &[DRIVE_CLIENT_SECRET, DRIVE_REFRESH_TOKEN],
1157        &SystemEnv,
1158    )?;
1159    let credentials =
1160        run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1161    save_credentials_to(settings_path, profile, &credentials)?;
1162    Ok(status_from_credentials(&credentials))
1163}
1164
1165/// [`login`], but honoring the named-account resolution (mirrors Gmail's
1166/// issue #1500): runs the same OAuth2 flow, then persists to
1167/// `drive.accounts.<name>` when a named account is active instead of the
1168/// legacy `env`/profile map. `explicit` is the already-resolved
1169/// `--account`/[`account::DRIVE_ACCOUNT_ENV`] override, if any — resolved
1170/// via [`resolve_for_write`], so an explicit name need not already be
1171/// configured (this is how a new account is created).
1172pub(crate) async fn login_for(
1173    explicit: Option<&str>,
1174    client_id: &str,
1175    client_secret: &Secret,
1176    scope: DriveGrantedScopes,
1177    browser: &BrowserConfig,
1178) -> Result<DriveAuthStatus> {
1179    let settings = Settings::load_or_warn_default();
1180    match resolve_for_write(&settings.drive, explicit)? {
1181        ResolvedAccount::Unconfigured => {
1182            login_to(
1183                &Settings::get_settings_path()?,
1184                active_profile_from(&SystemEnv).as_deref(),
1185                client_id,
1186                client_secret,
1187                scope,
1188                browser,
1189                TOKEN_ENDPOINT,
1190            )
1191            .await
1192        }
1193        ResolvedAccount::Named(name) => {
1194            let credentials =
1195                run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1196            Settings::upsert_drive_account(
1197                &Settings::get_settings_path()?,
1198                &name,
1199                &named_account_vars(&credentials),
1200            )?;
1201            Ok(status_from_credentials(&credentials))
1202        }
1203    }
1204}
1205
1206/// Runs the OAuth2 authorization-code + PKCE flow against `token_endpoint`
1207/// and returns the resulting credentials, without persisting them — the
1208/// shared core both [`login_to`] (legacy path) and [`login_for`]'s Named
1209/// branch build on.
1210async fn run_login_flow(
1211    client_id: &str,
1212    client_secret: &Secret,
1213    scope: DriveGrantedScopes,
1214    browser: &BrowserConfig,
1215    token_endpoint: &str,
1216) -> Result<DriveCredentials> {
1217    let (listener, port) = bind_callback_listener(browser).await?;
1218    let redirect_uri = format!("http://127.0.0.1:{port}");
1219
1220    let pending = generate_pending_login();
1221    let challenge = code_challenge(&pending.code_verifier);
1222    let auth_url =
1223        build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1224    open_browser(&browser.launch, auth_url.as_str())?;
1225
1226    let callback = wait_for_callback(listener).await?;
1227    if let Some(error) = callback.error {
1228        return Err(DriveError::authorization_denied(
1229            &error,
1230            callback.error_description.as_deref(),
1231        )
1232        .into());
1233    }
1234    let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1235        return Err(DriveError::MalformedCallback.into());
1236    };
1237    // Plain equality, not constant-time: `state` is a CSRF nonce carried in
1238    // a browser-visible URL, not a secret — there's nothing for a timing
1239    // side-channel to extract here (unlike `constant_time_eq`'s real use
1240    // guarding a bridge auth token in `src/browser/auth.rs`).
1241    if returned_state != pending.state {
1242        return Err(DriveError::StateMismatch.into());
1243    }
1244
1245    let http = reqwest::Client::builder()
1246        .connect_timeout(crate::utils::http::connect_timeout())
1247        .read_timeout(crate::utils::http::read_timeout())
1248        .build()
1249        .context("Failed to build HTTP client")?;
1250    let tokens = exchange_code_for_tokens(
1251        &http,
1252        token_endpoint,
1253        client_id,
1254        client_secret.expose_secret(),
1255        &code,
1256        &pending.code_verifier,
1257        &redirect_uri,
1258    )
1259    .await?;
1260    let refresh_token = tokens
1261        .refresh_token
1262        .ok_or(DriveError::MalformedTokenResponse("refresh_token"))?;
1263    let granted_raw = tokens.scope.unwrap_or_default();
1264    let granted_scope = DriveGrantedScopes::from_granted(&granted_raw).ok_or_else(|| {
1265        let received = if granted_raw.trim().is_empty() {
1266            "none".to_string()
1267        } else {
1268            granted_raw
1269                .split_whitespace()
1270                .collect::<Vec<_>>()
1271                .join(", ")
1272        };
1273        DriveError::NoScopeGranted(received)
1274    })?;
1275
1276    Ok(DriveCredentials {
1277        client_id: client_id.to_string(),
1278        client_secret: client_secret.clone(),
1279        refresh_token: refresh_token.into(),
1280        scope: granted_scope,
1281    })
1282}
1283
1284/// Builds the "just authenticated" [`DriveAuthStatus`] from freshly-obtained
1285/// `credentials` (all fields present by construction).
1286fn status_from_credentials(credentials: &DriveCredentials) -> DriveAuthStatus {
1287    DriveAuthStatus {
1288        has_client_id: true,
1289        has_client_secret: true,
1290        has_refresh_token: true,
1291        scope: Some(credentials.scope.as_str()),
1292    }
1293}
1294
1295#[cfg(test)]
1296#[allow(clippy::unwrap_used, clippy::expect_used)]
1297mod tests {
1298    use std::fs;
1299    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1300    use std::sync::Arc;
1301
1302    use super::*;
1303
1304    // ── Pure helpers ─────────────────────────────────────────────────
1305
1306    // ── DriveGrantedScopes ───────────────────────────────────────────
1307
1308    #[test]
1309    fn from_granted_sets_each_flag_independently_order_agnostic() {
1310        let scope =
1311            DriveGrantedScopes::from_granted(&format!("{SCOPE_FULL} {SCOPE_READONLY}")).unwrap();
1312        assert!(scope.readonly);
1313        assert!(!scope.metadata);
1314        assert!(!scope.file);
1315        assert!(scope.full);
1316    }
1317
1318    #[test]
1319    fn from_granted_recognizes_all_four_scopes_together() {
1320        let granted = format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}");
1321        let scope = DriveGrantedScopes::from_granted(&granted).unwrap();
1322        assert!(scope.readonly);
1323        assert!(scope.metadata);
1324        assert!(scope.file);
1325        assert!(scope.full);
1326    }
1327
1328    #[test]
1329    fn from_granted_returns_none_when_no_drive_scope_present() {
1330        assert_eq!(
1331            DriveGrantedScopes::from_granted("openid email profile"),
1332            None
1333        );
1334        assert_eq!(DriveGrantedScopes::from_granted(""), None);
1335    }
1336
1337    /// The bug from issue #1645, pinned at its sharpest: an account holding
1338    /// `readonly` + the unrestricted `drive` must not be described as
1339    /// holding `drive.metadata`. The old binary branch reported exactly
1340    /// that — naming a scope the account lacks while hiding the one it has,
1341    /// wrong in both directions at once.
1342    #[test]
1343    fn summary_never_invents_metadata_for_a_full_scope_account() {
1344        let scope = DriveGrantedScopes {
1345            readonly: true,
1346            metadata: false,
1347            file: false,
1348            full: true,
1349        };
1350        assert_eq!(scope.summary(), "drive.readonly, drive");
1351        assert!(
1352            !scope.summary().contains("metadata"),
1353            "the account holds no metadata scope"
1354        );
1355    }
1356
1357    /// The exact string `docs/drive.md` promises for a
1358    /// `--write --write-file` login — a written contract the previous
1359    /// implementation could not produce for any input.
1360    #[test]
1361    fn summary_matches_the_documented_write_write_file_example() {
1362        let scope = DriveGrantedScopes {
1363            readonly: true,
1364            metadata: true,
1365            file: true,
1366            full: false,
1367        };
1368        assert_eq!(
1369            scope.summary(),
1370            "drive.readonly, drive.metadata, drive.file"
1371        );
1372    }
1373
1374    /// Every one of the sixteen combinations renders exactly the flags that
1375    /// are set, in table order — which is the property a predicate branch
1376    /// cannot have.
1377    #[test]
1378    fn summary_renders_exactly_the_flags_that_are_set() {
1379        for bits in 0..16u8 {
1380            let scope = DriveGrantedScopes {
1381                readonly: bits & 1 != 0,
1382                metadata: bits & 2 != 0,
1383                file: bits & 4 != 0,
1384                full: bits & 8 != 0,
1385            };
1386            let summary = scope.summary();
1387            for (set, name) in [
1388                (scope.readonly, "drive.readonly"),
1389                (scope.metadata, "drive.metadata"),
1390                (scope.file, "drive.file"),
1391                (scope.full, "drive"),
1392            ] {
1393                // `drive` is a substring of the others, so compare against
1394                // the split fields rather than the joined string.
1395                let present = summary.split(", ").any(|part| part == name);
1396                assert_eq!(present, set, "{name} in {summary:?} (bits {bits})");
1397            }
1398        }
1399    }
1400
1401    #[test]
1402    fn summary_of_an_empty_set_is_not_an_empty_string() {
1403        assert_eq!(DriveGrantedScopes::default().summary(), "(none)");
1404    }
1405
1406    /// `summary` and `as_str` must always describe the same set — the short
1407    /// form is a rendering of the wire form, not a second source of truth.
1408    #[test]
1409    fn summary_and_as_str_agree_on_which_scopes_are_present() {
1410        for bits in 0..16u8 {
1411            let scope = DriveGrantedScopes {
1412                readonly: bits & 1 != 0,
1413                metadata: bits & 2 != 0,
1414                file: bits & 4 != 0,
1415                full: bits & 8 != 0,
1416            };
1417            let wire_count = scope.as_str().split_whitespace().count();
1418            let short_count = if scope.summary() == "(none)" {
1419                0
1420            } else {
1421                scope.summary().split(", ").count()
1422            };
1423            assert_eq!(wire_count, short_count, "bits {bits}");
1424        }
1425    }
1426
1427    #[test]
1428    fn as_str_round_trips_through_from_granted() {
1429        let scope = DriveGrantedScopes {
1430            readonly: true,
1431            metadata: false,
1432            file: true,
1433            full: false,
1434        };
1435        let round_tripped = DriveGrantedScopes::from_granted(&scope.as_str()).unwrap();
1436        assert_eq!(scope, round_tripped);
1437    }
1438
1439    #[test]
1440    fn as_str_orders_scopes_readonly_metadata_file_full() {
1441        let scope = DriveGrantedScopes {
1442            readonly: true,
1443            metadata: true,
1444            file: true,
1445            full: true,
1446        };
1447        assert_eq!(
1448            scope.as_str(),
1449            format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}")
1450        );
1451    }
1452
1453    #[test]
1454    fn allows_predicates_reflect_the_capability_each_names() {
1455        assert!(!DriveGrantedScopes::READONLY.allows_write());
1456        assert!(!DriveGrantedScopes::READONLY.allows_rename_move());
1457        assert!(!DriveGrantedScopes::READONLY.allows_create_or_upload());
1458        assert!(!DriveGrantedScopes::READONLY.allows_edit_arbitrary_content());
1459
1460        assert!(DriveGrantedScopes::METADATA.allows_write());
1461        assert!(DriveGrantedScopes::METADATA.allows_rename_move());
1462        assert!(!DriveGrantedScopes::METADATA.allows_create_or_upload());
1463
1464        let file_only = DriveGrantedScopes {
1465            readonly: true,
1466            metadata: false,
1467            file: true,
1468            full: false,
1469        };
1470        assert!(file_only.allows_create_or_upload());
1471        assert!(
1472            !file_only.allows_edit_arbitrary_content(),
1473            "drive.file alone cannot edit an arbitrary pre-existing file"
1474        );
1475
1476        let full_only = DriveGrantedScopes {
1477            readonly: true,
1478            metadata: false,
1479            file: false,
1480            full: true,
1481        };
1482        assert!(full_only.allows_create_or_upload());
1483        assert!(full_only.allows_edit_arbitrary_content());
1484    }
1485
1486    #[test]
1487    fn code_challenge_matches_rfc_7636_test_vector() {
1488        // RFC 7636 Appendix B.1.
1489        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1490        let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1491        assert_eq!(code_challenge(verifier), expected);
1492    }
1493
1494    #[test]
1495    fn code_challenge_output_is_url_safe_no_padding() {
1496        let challenge = code_challenge("some-verifier-value");
1497        assert!(!challenge.contains('+'));
1498        assert!(!challenge.contains('/'));
1499        assert!(!challenge.contains('='));
1500    }
1501
1502    #[test]
1503    fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1504        let pending = generate_pending_login();
1505        assert_ne!(pending.state, pending.code_verifier);
1506        assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1507        assert!(pending
1508            .code_verifier
1509            .chars()
1510            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1511    }
1512
1513    #[test]
1514    fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1515        let url = build_authorization_url(
1516            "client-123",
1517            "http://127.0.0.1:5555",
1518            DriveGrantedScopes::READONLY,
1519            "state-abc",
1520            "challenge-xyz",
1521        )
1522        .unwrap();
1523        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1524        assert_eq!(query.get("client_id").unwrap(), "client-123");
1525        assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1526        assert_eq!(query.get("response_type").unwrap(), "code");
1527        assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1528        assert_eq!(query.get("state").unwrap(), "state-abc");
1529        assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1530        assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1531        assert_eq!(query.get("access_type").unwrap(), "offline");
1532        assert_eq!(query.get("prompt").unwrap(), "consent");
1533    }
1534
1535    #[test]
1536    fn build_authorization_url_uses_additive_scope_when_metadata_requested() {
1537        let url = build_authorization_url(
1538            "client-123",
1539            "http://127.0.0.1:5555",
1540            DriveGrantedScopes::METADATA,
1541            "state-abc",
1542            "challenge-xyz",
1543        )
1544        .unwrap();
1545        let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1546        assert_eq!(
1547            query.get("scope").unwrap(),
1548            &format!("{SCOPE_READONLY} {SCOPE_METADATA}")
1549        );
1550    }
1551
1552    #[test]
1553    fn parse_callback_extracts_code_and_state() {
1554        let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1555        let result = parse_callback(request).unwrap();
1556        assert_eq!(result.code.as_deref(), Some("abc123"));
1557        assert_eq!(result.state.as_deref(), Some("xyz789"));
1558        assert!(result.error.is_none());
1559    }
1560
1561    #[test]
1562    fn parse_callback_extracts_error_and_error_description() {
1563        let request =
1564            "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1565        let result = parse_callback(request).unwrap();
1566        assert_eq!(result.error.as_deref(), Some("access_denied"));
1567        assert_eq!(result.error_description.as_deref(), Some("user declined"));
1568    }
1569
1570    #[test]
1571    fn parse_callback_missing_query_string_is_none() {
1572        assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1573        assert!(parse_callback("garbage").is_none());
1574    }
1575
1576    #[test]
1577    fn parse_callback_ignores_unrecognized_query_keys() {
1578        let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1579        let result = parse_callback(request).unwrap();
1580        assert_eq!(result.code.as_deref(), Some("abc"));
1581        assert_eq!(result.state.as_deref(), Some("xyz"));
1582    }
1583
1584    #[test]
1585    fn open_browser_manual_logs_and_succeeds() {
1586        assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1587    }
1588
1589    #[test]
1590    fn open_browser_command_substitutes_url_placeholder() {
1591        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1592        assert!(open_browser(&launch, "https://example/auth").is_ok());
1593    }
1594
1595    #[test]
1596    fn open_browser_command_appends_url_when_no_placeholder() {
1597        let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1598        assert!(open_browser(&launch, "https://example/auth").is_ok());
1599    }
1600
1601    #[test]
1602    fn open_browser_command_passes_through_args_without_the_placeholder() {
1603        // A trailing flag with no `{url}` substring (e.g. `--verbose`) is
1604        // passed to the command unmodified, and the URL is still appended
1605        // since no arg claimed the placeholder.
1606        let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1607        assert!(open_browser(&launch, "https://example/auth").is_ok());
1608    }
1609
1610    #[test]
1611    fn open_browser_command_rejects_empty_args() {
1612        let launch = BrowserLaunch::Command(vec![]);
1613        let err = open_browser(&launch, "u").unwrap_err();
1614        assert!(err.to_string().contains("empty browser command"));
1615    }
1616
1617    // ── named_account_vars ───────────────────────────────────────────────
1618
1619    #[test]
1620    fn named_account_vars_maps_credentials_to_json_string_values() {
1621        let credentials = DriveCredentials {
1622            client_id: "client-1".to_string(),
1623            client_secret: Secret::new("secret-1"),
1624            refresh_token: Secret::new("refresh-1"),
1625            scope: DriveGrantedScopes::READONLY,
1626        };
1627        assert_eq!(
1628            named_account_vars(&credentials),
1629            [
1630                (
1631                    "client_id",
1632                    serde_json::Value::String("client-1".to_string())
1633                ),
1634                (
1635                    "client_secret",
1636                    serde_json::Value::String("secret-1".to_string())
1637                ),
1638                (
1639                    "refresh_token",
1640                    serde_json::Value::String("refresh-1".to_string())
1641                ),
1642                (
1643                    "scope",
1644                    serde_json::Value::String(SCOPE_READONLY.to_string())
1645                ),
1646            ]
1647        );
1648    }
1649
1650    // ── build_browser_config (mirrors Gmail's issue #1505) ──────────────
1651
1652    fn assert_is_auto(config: BrowserConfig) {
1653        assert!(matches!(config.launch, BrowserLaunch::Auto));
1654    }
1655
1656    #[test]
1657    fn build_browser_config_defaults_to_auto_with_no_account() {
1658        assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1659    }
1660
1661    #[test]
1662    fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1663        let account = DriveAccountSettings {
1664            email_address: Some("alice@example.com".to_string()),
1665            ..DriveAccountSettings::default()
1666        };
1667        // chrome_profile_from_email is false, so the resolver must never run
1668        // even though email_address is set.
1669        assert_is_auto(
1670            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1671        );
1672    }
1673
1674    #[test]
1675    fn build_browser_config_uses_browser_command_verbatim() {
1676        let account = DriveAccountSettings {
1677            browser_command: Some("chrome --new-window {url}".to_string()),
1678            ..DriveAccountSettings::default()
1679        };
1680        let config =
1681            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1682        assert!(matches!(
1683            config.launch,
1684            BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1685        ));
1686    }
1687
1688    #[test]
1689    fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1690        let account = DriveAccountSettings {
1691            browser_command: Some("chrome {url}".to_string()),
1692            chrome_profile_from_email: true,
1693            email_address: Some("alice@example.com".to_string()),
1694            ..DriveAccountSettings::default()
1695        };
1696        let config =
1697            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1698        assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1699    }
1700
1701    #[test]
1702    fn build_browser_config_rejects_a_malformed_browser_command() {
1703        let account = DriveAccountSettings {
1704            browser_command: Some("chrome \"--flag".to_string()),
1705            ..DriveAccountSettings::default()
1706        };
1707        let err =
1708            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1709        assert!(err.to_string().contains("browser_command"));
1710    }
1711
1712    #[test]
1713    fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1714        let account = DriveAccountSettings {
1715            chrome_profile_from_email: true,
1716            email_address: Some("alice@example.com".to_string()),
1717            ..DriveAccountSettings::default()
1718        };
1719        let config = build_browser_config(Some(&account), |email| {
1720            assert_eq!(email, "alice@example.com");
1721            Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1722        })
1723        .unwrap();
1724        assert!(matches!(
1725            config.launch,
1726            BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1727        ));
1728    }
1729
1730    #[test]
1731    fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1732        let account = DriveAccountSettings {
1733            chrome_profile_from_email: true,
1734            email_address: Some("alice@example.com".to_string()),
1735            ..DriveAccountSettings::default()
1736        };
1737        assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1738    }
1739
1740    #[test]
1741    fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1742        let account = DriveAccountSettings {
1743            chrome_profile_from_email: true,
1744            ..DriveAccountSettings::default()
1745        };
1746        assert_is_auto(
1747            build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1748        );
1749    }
1750
1751    // ── resolve_browser_config_for (mirrors Gmail's issue #1505) ────────
1752
1753    #[test]
1754    fn resolve_browser_config_for_unconfigured_account_defaults_to_auto() {
1755        let guard = crate::drive::test_support::EnvGuard::take();
1756        let _dir = guard.clear_credentials();
1757        std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
1758        std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
1759        std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
1760
1761        let drive = DriveSettings::default();
1762        assert_is_auto(resolve_browser_config_for(&drive, None).unwrap());
1763    }
1764
1765    #[test]
1766    fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1767        let guard = crate::drive::test_support::EnvGuard::take();
1768        let _dir = guard.clear_credentials();
1769
1770        let mut drive = DriveSettings::default();
1771        drive.accounts.insert(
1772            "work".to_string(),
1773            DriveAccountSettings {
1774                email_address: Some("alice@example.com".to_string()),
1775                ..DriveAccountSettings::default()
1776            },
1777        );
1778
1779        // chrome_profile_from_email is false, so this never touches the
1780        // real chrome_profile::resolve_launch_command resolver.
1781        assert_is_auto(resolve_browser_config_for(&drive, Some("work")).unwrap());
1782    }
1783
1784    // ── Loopback listener (real sockets, no wiremock) ───────────────────
1785
1786    #[tokio::test]
1787    async fn wait_for_callback_times_out_when_nothing_connects() {
1788        let browser = BrowserConfig::default();
1789        let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1790        let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1791            .await
1792            .unwrap_err();
1793        assert!(matches!(
1794            err.downcast_ref::<DriveError>(),
1795            Some(DriveError::CallbackTimeout(_))
1796        ));
1797    }
1798
1799    #[tokio::test]
1800    async fn wait_for_callback_reads_a_real_connection() {
1801        let browser = BrowserConfig::default();
1802        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1803
1804        let client = tokio::spawn(async move {
1805            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1806                .await
1807                .unwrap();
1808            stream
1809                .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1810                .await
1811                .unwrap();
1812        });
1813
1814        let result = wait_for_callback(listener).await.unwrap();
1815        client.await.unwrap();
1816        assert_eq!(result.code.as_deref(), Some("abc"));
1817        assert_eq!(result.state.as_deref(), Some("xyz"));
1818    }
1819
1820    #[tokio::test]
1821    async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1822        let browser = BrowserConfig::default();
1823        let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1824
1825        let client = tokio::spawn(async move {
1826            let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1827                .await
1828                .unwrap();
1829            stream.write_all(b"not an http request").await.unwrap();
1830        });
1831
1832        let err = wait_for_callback(listener).await.unwrap_err();
1833        client.await.unwrap();
1834        assert!(matches!(
1835            err.downcast_ref::<DriveError>(),
1836            Some(DriveError::MalformedCallback)
1837        ));
1838    }
1839
1840    // ── Token exchange / refresh (wiremock) ─────────────────────────────
1841
1842    #[tokio::test]
1843    async fn exchange_code_for_tokens_posts_expected_form_body() {
1844        let server = wiremock::MockServer::start().await;
1845        wiremock::Mock::given(wiremock::matchers::method("POST"))
1846            .and(wiremock::matchers::path("/token"))
1847            .and(wiremock::matchers::body_string_contains(
1848                "grant_type=authorization_code",
1849            ))
1850            .and(wiremock::matchers::body_string_contains(
1851                "code_verifier=verifier-1",
1852            ))
1853            .and(wiremock::matchers::body_string_contains(
1854                "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1855            ))
1856            .respond_with(
1857                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1858                    "access_token": "at-1",
1859                    "refresh_token": "rt-1",
1860                    "expires_in": 3600,
1861                    "scope": SCOPE_READONLY,
1862                })),
1863            )
1864            .expect(1)
1865            .mount(&server)
1866            .await;
1867
1868        let http = reqwest::Client::new();
1869        let token_endpoint = format!("{}/token", server.uri());
1870        let response = exchange_code_for_tokens(
1871            &http,
1872            &token_endpoint,
1873            "client-1",
1874            "secret-1",
1875            "code-1",
1876            "verifier-1",
1877            "http://127.0.0.1:9999",
1878        )
1879        .await
1880        .unwrap();
1881        assert_eq!(response.access_token, "at-1");
1882        assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1883    }
1884
1885    #[tokio::test]
1886    async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1887        let server = wiremock::MockServer::start().await;
1888        wiremock::Mock::given(wiremock::matchers::method("POST"))
1889            .respond_with(
1890                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1891                    "error": "invalid_grant",
1892                    "error_description": "Bad Request",
1893                })),
1894            )
1895            .mount(&server)
1896            .await;
1897
1898        let http = reqwest::Client::new();
1899        let err = exchange_code_for_tokens(
1900            &http,
1901            &server.uri(),
1902            "c",
1903            "s",
1904            "code",
1905            "verifier",
1906            "http://127.0.0.1:1",
1907        )
1908        .await
1909        .unwrap_err();
1910        assert!(err.to_string().contains("PKCE"));
1911    }
1912
1913    // ── login_to (end-to-end: state mismatch / access_denied) ───────────
1914    //
1915    // These exercise `login_to` itself rather than its sub-components in
1916    // isolation, so a regression in how it wires the loopback callback into
1917    // the state-mismatch/access_denied branches would actually be caught.
1918    // The callback port is picked by binding-then-dropping a std listener
1919    // (a well-known "reserve a free port" trick) so the test's connector
1920    // task can dial it directly — `login_to` binds the real listener before
1921    // opening the browser, so the connector retries briefly to cover the
1922    // small window before that bind completes.
1923    //
1924    // That reserve-then-drop is itself a TOCTOU race against the rest of the
1925    // parallel test suite: another test can grab the same ephemeral port
1926    // before `login_to`'s own bind runs, which fails outright rather than
1927    // retrying (mirrors Gmail's issue #1489). `run_with_port_retry` bounds a
1928    // retry of the whole reserve/connect/bind attempt on exactly that
1929    // failure, so a lost race just tries again with a fresh port instead of
1930    // flaking.
1931
1932    async fn connect_and_send(port: u16, request_line: &[u8]) {
1933        let mut stream = loop {
1934            match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1935                Ok(stream) => break stream,
1936                Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1937            }
1938        };
1939        stream.write_all(request_line).await.unwrap();
1940    }
1941
1942    fn reserve_free_port() -> u16 {
1943        std::net::TcpListener::bind("127.0.0.1:0")
1944            .unwrap()
1945            .local_addr()
1946            .unwrap()
1947            .port()
1948    }
1949
1950    /// True if `err` is `bind_callback_listener`'s wrapped `AddrInUse` —
1951    /// i.e. some other process/test won the race for the port reserved by
1952    /// [`reserve_free_port`] before `login_to` could rebind it.
1953    fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1954        err.to_string()
1955            .contains("Failed to start the local OAuth callback listener")
1956    }
1957
1958    const PORT_RETRY_ATTEMPTS: u32 = 5;
1959
1960    /// Runs `attempt`, which reserves its own port via [`reserve_free_port`]
1961    /// and returns `login_to`'s result, retrying up to
1962    /// [`PORT_RETRY_ATTEMPTS`] times when the attempt loses the ephemeral
1963    /// port race (see the module comment above `connect_and_send`).
1964    async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<DriveAuthStatus>
1965    where
1966        F: FnMut(u16) -> Fut,
1967        Fut: std::future::Future<Output = Result<DriveAuthStatus>>,
1968    {
1969        for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1970            let result = attempt(reserve_free_port()).await;
1971            let is_retryable_conflict =
1972                matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1973            if !is_retryable_conflict {
1974                return result;
1975            }
1976        }
1977        unreachable!("loop always returns on its last iteration")
1978    }
1979
1980    /// Awaits `connector` normally, unless `result` shows `login_to` lost
1981    /// the callback-port race — in which case the connector, which will
1982    /// never see a connection on the now-taken port, is aborted instead of
1983    /// hung.
1984    async fn finish_connector(
1985        connector: tokio::task::JoinHandle<()>,
1986        result: &Result<DriveAuthStatus>,
1987    ) {
1988        match result {
1989            Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1990            _ => connector.await.unwrap(),
1991        }
1992    }
1993
1994    /// Polls `path` until it holds non-empty content, then returns it —
1995    /// used to read back the authorization URL that `open_browser`'s
1996    /// captured shell command writes asynchronously.
1997    async fn wait_for_captured_url(path: &Path) -> String {
1998        loop {
1999            if let Ok(contents) = std::fs::read_to_string(path) {
2000                if !contents.is_empty() {
2001                    return contents;
2002                }
2003            }
2004            tokio::time::sleep(Duration::from_millis(2)).await;
2005        }
2006    }
2007
2008    #[tokio::test]
2009    async fn login_to_refuses_before_the_browser_flow_when_the_token_is_command_fetched() {
2010        let temp_dir = tempfile::TempDir::new().unwrap();
2011        let settings_path = temp_dir.path().join("settings.json");
2012        std::fs::write(
2013            &settings_path,
2014            r#"{"env": {"DRIVE_REFRESH_TOKEN_COMMAND": "op read op://v/secret"}}"#,
2015        )
2016        .unwrap();
2017        let browser = BrowserConfig {
2018            launch: BrowserLaunch::Manual,
2019            callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2020            callback_port: 0,
2021        };
2022        // Manual launch would wait for a callback that never comes, so a
2023        // timeout means the refusal came too late.
2024        let result = tokio::time::timeout(
2025            Duration::from_secs(5),
2026            login_to(
2027                &settings_path,
2028                None,
2029                "client-id",
2030                &Secret::new("client-secret"),
2031                DriveGrantedScopes::READONLY,
2032                &browser,
2033                "http://127.0.0.1:1/token",
2034            ),
2035        )
2036        .await
2037        .expect("login must refuse before starting the browser flow");
2038        let err = result.unwrap_err().to_string();
2039        assert!(err.contains("DRIVE_REFRESH_TOKEN_COMMAND"), "{err}");
2040        assert!(!err.contains("op read"), "{err}");
2041    }
2042
2043    /// Shared body for the three `login_to_*` tests that drive a single
2044    /// fixed callback request line through `login_to` and expect it to
2045    /// error: reserves a port, spawns the connector, calls `login_to`, and
2046    /// retries the whole attempt (via [`run_with_port_retry`]) if it loses
2047    /// the ephemeral-port race. Asserts no settings file was written and
2048    /// returns the resulting error for the caller to inspect.
2049    async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
2050        std::fs::create_dir_all("tmp").ok();
2051        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2052        let settings_path = temp_dir.path().join("settings.json");
2053
2054        let result = run_with_port_retry(|port| {
2055            let settings_path = settings_path.clone();
2056            async move {
2057                let browser = BrowserConfig {
2058                    launch: BrowserLaunch::Manual,
2059                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2060                    callback_port: port,
2061                };
2062                let connector = tokio::spawn(connect_and_send(port, request_line));
2063
2064                let result = login_to(
2065                    &settings_path,
2066                    None,
2067                    "client-id",
2068                    &Secret::new("client-secret"),
2069                    DriveGrantedScopes::READONLY,
2070                    &browser,
2071                    "http://127.0.0.1:1/token", // never reached — fails before exchange
2072                )
2073                .await;
2074
2075                finish_connector(connector, &result).await;
2076                result
2077            }
2078        })
2079        .await;
2080
2081        let err = result.unwrap_err();
2082        assert!(!settings_path.exists());
2083        err
2084    }
2085
2086    #[tokio::test]
2087    async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
2088        let attempts = AtomicU32::new(0);
2089
2090        let status = run_with_port_retry(|_port| {
2091            let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
2092            async move {
2093                if attempt_no == 0 {
2094                    Err(anyhow::anyhow!(
2095                        "Failed to start the local OAuth callback listener: address in use"
2096                    ))
2097                } else {
2098                    Ok(DriveAuthStatus {
2099                        has_client_id: true,
2100                        has_client_secret: true,
2101                        has_refresh_token: true,
2102                        scope: None,
2103                    })
2104                }
2105            }
2106        })
2107        .await
2108        .unwrap();
2109
2110        assert_eq!(attempts.load(Ordering::SeqCst), 2);
2111        assert!(status.has_client_id);
2112    }
2113
2114    #[tokio::test]
2115    async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
2116        let attempts = AtomicU32::new(0);
2117
2118        let result = run_with_port_retry(|_port| {
2119            attempts.fetch_add(1, Ordering::SeqCst);
2120            async move { Err(anyhow::anyhow!("some other failure")) }
2121        })
2122        .await;
2123
2124        assert_eq!(attempts.load(Ordering::SeqCst), 1);
2125        assert!(result.is_err());
2126    }
2127
2128    #[tokio::test]
2129    async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
2130        let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
2131        let connector = tokio::spawn(async move {
2132            tokio::time::sleep(Duration::from_secs(3600)).await;
2133            let _ = tx.send(());
2134        });
2135        let result: Result<DriveAuthStatus> = Err(anyhow::anyhow!(
2136            "Failed to start the local OAuth callback listener: address in use"
2137        ));
2138
2139        tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
2140            .await
2141            .expect("finish_connector must not wait for an aborted connector");
2142
2143        assert!(
2144            rx.try_recv().is_err(),
2145            "connector must have been aborted, not run to completion"
2146        );
2147    }
2148
2149    #[tokio::test]
2150    async fn finish_connector_awaits_connector_when_login_to_succeeds() {
2151        let ran = Arc::new(AtomicBool::new(false));
2152        let ran_clone = ran.clone();
2153        let connector = tokio::spawn(async move {
2154            ran_clone.store(true, Ordering::SeqCst);
2155        });
2156        let result = Ok(DriveAuthStatus {
2157            has_client_id: true,
2158            has_client_secret: true,
2159            has_refresh_token: true,
2160            scope: None,
2161        });
2162
2163        finish_connector(connector, &result).await;
2164
2165        assert!(ran.load(Ordering::SeqCst));
2166    }
2167
2168    #[tokio::test]
2169    async fn wait_for_captured_url_polls_until_content_is_written() {
2170        std::fs::create_dir_all("tmp").ok();
2171        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2172        let path = temp_dir.path().join("captured-url.txt");
2173
2174        let write_path = path.clone();
2175        tokio::spawn(async move {
2176            tokio::time::sleep(Duration::from_millis(10)).await;
2177            std::fs::write(&write_path, "").unwrap();
2178            tokio::time::sleep(Duration::from_millis(10)).await;
2179            std::fs::write(&write_path, "https://example.com/authorize").unwrap();
2180        });
2181
2182        let contents = wait_for_captured_url(&path).await;
2183        assert_eq!(contents, "https://example.com/authorize");
2184    }
2185
2186    #[tokio::test]
2187    async fn login_to_rejects_a_callback_with_mismatched_state() {
2188        let err =
2189            run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
2190
2191        assert!(matches!(
2192            err.downcast_ref::<DriveError>(),
2193            Some(DriveError::StateMismatch)
2194        ));
2195    }
2196
2197    #[tokio::test]
2198    async fn login_to_surfaces_access_denied_from_the_callback() {
2199        let err = run_login_to_expect_err(
2200            b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
2201        )
2202        .await;
2203
2204        match err.downcast_ref::<DriveError>() {
2205            Some(DriveError::AuthorizationDenied(message)) => {
2206                assert!(message.contains("access_denied"));
2207                assert!(message.contains("user declined"));
2208            }
2209            other => panic!("expected AuthorizationDenied, got {other:?}"),
2210        }
2211    }
2212
2213    #[tokio::test]
2214    async fn login_to_rejects_a_callback_missing_code_and_state() {
2215        let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
2216
2217        assert!(matches!(
2218            err.downcast_ref::<DriveError>(),
2219            Some(DriveError::MalformedCallback)
2220        ));
2221    }
2222
2223    #[tokio::test]
2224    async fn login_to_completes_full_success_flow_and_persists_credentials() {
2225        // Captures the real authorization URL `login_to` generates (with its
2226        // randomly-generated CSRF `state`) by pointing the browser launch at
2227        // a shell command instead of an actual browser: `open_browser`
2228        // substitutes `{url}` into the command's args and spawns it, so a
2229        // tiny `/bin/sh` one-liner writes the URL to a file we can read back
2230        // — letting this test drive the full success path (state echoed
2231        // correctly, token exchange, credential persistence) without ever
2232        // opening a real browser or needing to predict the CSRF nonce.
2233        std::fs::create_dir_all("tmp").ok();
2234        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2235        let capture_path = temp_dir.path().join("captured-url.txt");
2236        let settings_path = temp_dir.path().join("settings.json");
2237
2238        let server = wiremock::MockServer::start().await;
2239        wiremock::Mock::given(wiremock::matchers::method("POST"))
2240            .and(wiremock::matchers::path("/token"))
2241            .respond_with(
2242                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2243                    "access_token": "at-1",
2244                    "refresh_token": "rt-1",
2245                    "expires_in": 3600,
2246                    "scope": SCOPE_READONLY,
2247                })),
2248            )
2249            .expect(1)
2250            .mount(&server)
2251            .await;
2252
2253        let status = run_with_port_retry(|port| {
2254            let capture_path = capture_path.clone();
2255            let settings_path = settings_path.clone();
2256            let token_endpoint = format!("{}/token", server.uri());
2257            async move {
2258                let browser = BrowserConfig {
2259                    launch: BrowserLaunch::Command(vec![
2260                        "/bin/sh".to_string(),
2261                        "-c".to_string(),
2262                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2263                        "{url}".to_string(),
2264                    ]),
2265                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2266                    callback_port: port,
2267                };
2268
2269                let connector = tokio::spawn(async move {
2270                    let auth_url = wait_for_captured_url(&capture_path).await;
2271                    let parsed = Url::parse(&auth_url).unwrap();
2272                    let state = parsed
2273                        .query_pairs()
2274                        .find(|(k, _)| k == "state")
2275                        .map(|(_, v)| v.into_owned())
2276                        .expect("authorization URL must carry a state param");
2277                    connect_and_send(
2278                        port,
2279                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2280                    )
2281                    .await;
2282                });
2283
2284                let result = login_to(
2285                    &settings_path,
2286                    None,
2287                    "client-id",
2288                    &Secret::new("client-secret"),
2289                    DriveGrantedScopes::READONLY,
2290                    &browser,
2291                    &token_endpoint,
2292                )
2293                .await;
2294
2295                finish_connector(connector, &result).await;
2296                result
2297            }
2298        })
2299        .await
2300        .unwrap();
2301
2302        assert!(status.has_client_id);
2303        assert!(status.has_client_secret);
2304        assert!(status.has_refresh_token);
2305        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2306
2307        let saved = std::fs::read_to_string(&settings_path).unwrap();
2308        assert!(saved.contains("rt-1"));
2309        assert!(saved.contains("client-id"));
2310    }
2311
2312    /// Full mocked login round trip (real state nonce echoed back via the
2313    /// captured-authorization-URL trick, like
2314    /// `login_to_completes_full_success_flow_and_persists_credentials`
2315    /// above), with an injectable token-response body — the seam the
2316    /// scope-validation tests below use to simulate Google granting no
2317    /// Drive scope.
2318    async fn run_login_to_with_token_response(
2319        token_response_body: serde_json::Value,
2320    ) -> (Result<DriveAuthStatus>, std::path::PathBuf) {
2321        std::fs::create_dir_all("tmp").ok();
2322        let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2323        let capture_path = temp_dir.path().join("captured-url.txt");
2324        let settings_path = temp_dir.path().join("settings.json");
2325
2326        let server = wiremock::MockServer::start().await;
2327        wiremock::Mock::given(wiremock::matchers::method("POST"))
2328            .and(wiremock::matchers::path("/token"))
2329            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
2330            .expect(1)
2331            .mount(&server)
2332            .await;
2333
2334        let result = run_with_port_retry(|port| {
2335            let capture_path = capture_path.clone();
2336            let settings_path = settings_path.clone();
2337            let token_endpoint = format!("{}/token", server.uri());
2338            async move {
2339                let browser = BrowserConfig {
2340                    launch: BrowserLaunch::Command(vec![
2341                        "/bin/sh".to_string(),
2342                        "-c".to_string(),
2343                        format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2344                        "{url}".to_string(),
2345                    ]),
2346                    callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2347                    callback_port: port,
2348                };
2349
2350                let connector = tokio::spawn(async move {
2351                    let auth_url = wait_for_captured_url(&capture_path).await;
2352                    let parsed = Url::parse(&auth_url).unwrap();
2353                    let state = parsed
2354                        .query_pairs()
2355                        .find(|(k, _)| k == "state")
2356                        .map(|(_, v)| v.into_owned())
2357                        .expect("authorization URL must carry a state param");
2358                    connect_and_send(
2359                        port,
2360                        format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2361                    )
2362                    .await;
2363                });
2364
2365                let result = login_to(
2366                    &settings_path,
2367                    None,
2368                    "client-id",
2369                    &Secret::new("client-secret"),
2370                    DriveGrantedScopes::READONLY,
2371                    &browser,
2372                    &token_endpoint,
2373                )
2374                .await;
2375
2376                finish_connector(connector, &result).await;
2377                result
2378            }
2379        })
2380        .await;
2381
2382        (result, settings_path)
2383    }
2384
2385    #[tokio::test]
2386    async fn login_to_rejects_a_grant_with_no_drive_scope() {
2387        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2388            "access_token": "at-1",
2389            "refresh_token": "rt-1",
2390            "expires_in": 3600,
2391            "scope": "openid email profile",
2392        }))
2393        .await;
2394
2395        let err = result.unwrap_err();
2396        assert!(matches!(
2397            err.downcast_ref::<DriveError>(),
2398            Some(DriveError::NoScopeGranted(received)) if received == "openid, email, profile"
2399        ));
2400        assert!(!settings_path.exists());
2401    }
2402
2403    #[tokio::test]
2404    async fn login_to_rejects_a_grant_with_missing_scope_field() {
2405        let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2406            "access_token": "at-1",
2407            "refresh_token": "rt-1",
2408            "expires_in": 3600,
2409        }))
2410        .await;
2411
2412        let err = result.unwrap_err();
2413        assert!(matches!(
2414            err.downcast_ref::<DriveError>(),
2415            Some(DriveError::NoScopeGranted(received)) if received == "none"
2416        ));
2417        assert!(!settings_path.exists());
2418    }
2419
2420    // ── login_for (named-account login orchestration, mirrors Gmail's
2421    // issue #1500) ───────────────────────────────────────────────────────
2422    //
2423    // `login_for` hardcodes the real `TOKEN_ENDPOINT` in both branches
2424    // (unlike `login_to`, which takes one as an explicit test seam), so a
2425    // full success round trip can't be driven against a wiremock server
2426    // here. These instead drive a callback with a mismatched `state` —
2427    // which `run_login_flow` rejects *before* ever reaching the token
2428    // endpoint — to exercise account resolution (`Settings::load` +
2429    // `resolve_for_write`) and, for the named branch, the `run_login_flow`
2430    // call site itself, without any real network call.
2431
2432    #[tokio::test]
2433    async fn login_for_unconfigured_account_rejects_a_callback_with_mismatched_state() {
2434        let guard = crate::drive::test_support::EnvGuard::take();
2435        let dir = guard.clear_credentials();
2436        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2437
2438        let result = run_with_port_retry(|port| async move {
2439            let browser = BrowserConfig {
2440                launch: BrowserLaunch::Manual,
2441                callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2442                callback_port: port,
2443            };
2444            let connector = tokio::spawn(connect_and_send(
2445                port,
2446                b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2447            ));
2448
2449            let result = login_for(
2450                None,
2451                "client-id",
2452                &Secret::new("client-secret"),
2453                DriveGrantedScopes::READONLY,
2454                &browser,
2455            )
2456            .await;
2457
2458            finish_connector(connector, &result).await;
2459            result
2460        })
2461        .await;
2462
2463        let err = result.unwrap_err();
2464        assert!(matches!(
2465            err.downcast_ref::<DriveError>(),
2466            Some(DriveError::StateMismatch)
2467        ));
2468        assert!(!settings_path.exists());
2469    }
2470
2471    #[tokio::test]
2472    async fn login_for_named_account_rejects_a_callback_with_mismatched_state() {
2473        let guard = crate::drive::test_support::EnvGuard::take();
2474        let dir = guard.clear_credentials();
2475        let settings_path = dir.path().join(".omni-dev").join("settings.json");
2476
2477        let result = run_with_port_retry(|port| async move {
2478            let browser = BrowserConfig {
2479                launch: BrowserLaunch::Manual,
2480                callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2481                callback_port: port,
2482            };
2483            let connector = tokio::spawn(connect_and_send(
2484                port,
2485                b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2486            ));
2487
2488            let result = login_for(
2489                Some("work"),
2490                "client-id",
2491                &Secret::new("client-secret"),
2492                DriveGrantedScopes::READONLY,
2493                &browser,
2494            )
2495            .await;
2496
2497            finish_connector(connector, &result).await;
2498            result
2499        })
2500        .await;
2501
2502        let err = result.unwrap_err();
2503        assert!(matches!(
2504            err.downcast_ref::<DriveError>(),
2505            Some(DriveError::StateMismatch)
2506        ));
2507        assert!(!settings_path.exists());
2508    }
2509
2510    #[tokio::test]
2511    async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2512        let server = wiremock::MockServer::start().await;
2513        wiremock::Mock::given(wiremock::matchers::method("POST"))
2514            .and(wiremock::matchers::body_string_contains(
2515                "grant_type=refresh_token",
2516            ))
2517            .respond_with(
2518                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2519                    "access_token": "at-2",
2520                    "expires_in": 1800,
2521                })),
2522            )
2523            .expect(1)
2524            .mount(&server)
2525            .await;
2526
2527        let http = reqwest::Client::new();
2528        let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2529            .await
2530            .unwrap();
2531        assert_eq!(response.access_token, "at-2");
2532        assert_eq!(response.expires_in, 1800);
2533    }
2534
2535    #[tokio::test]
2536    async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2537        let server = wiremock::MockServer::start().await;
2538        wiremock::Mock::given(wiremock::matchers::method("POST"))
2539            .respond_with(
2540                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2541                    "error": "invalid_grant",
2542                })),
2543            )
2544            .mount(&server)
2545            .await;
2546
2547        let http = reqwest::Client::new();
2548        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2549            .await
2550            .unwrap_err();
2551        let msg = err.to_string();
2552        assert!(msg.contains("7 days"));
2553        assert!(msg.contains("Testing"));
2554    }
2555
2556    #[tokio::test]
2557    async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2558        let server = wiremock::MockServer::start().await;
2559        wiremock::Mock::given(wiremock::matchers::method("POST"))
2560            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2561            .mount(&server)
2562            .await;
2563
2564        let http = reqwest::Client::new();
2565        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2566            .await
2567            .unwrap_err();
2568        let msg = err.to_string();
2569        assert!(msg.contains("unparsable error body"));
2570        assert!(msg.contains("not json"));
2571    }
2572
2573    #[tokio::test]
2574    async fn token_request_propagates_network_errors() {
2575        let http = reqwest::Client::new();
2576        let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2577            .await
2578            .unwrap_err();
2579        assert!(err.to_string().contains("Failed to send token request"));
2580    }
2581
2582    #[tokio::test]
2583    async fn token_request_errors_on_unparsable_response_body() {
2584        let server = wiremock::MockServer::start().await;
2585        wiremock::Mock::given(wiremock::matchers::method("POST"))
2586            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2587            .mount(&server)
2588            .await;
2589
2590        let http = reqwest::Client::new();
2591        let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2592            .await
2593            .unwrap_err();
2594        assert!(err.to_string().contains("Failed to parse"));
2595    }
2596
2597    // ── DriveSession ─────────────────────────────────────────────────
2598
2599    fn test_credentials() -> DriveCredentials {
2600        DriveCredentials {
2601            client_id: "client-1".to_string(),
2602            client_secret: "secret-1".into(),
2603            refresh_token: "refresh-1".into(),
2604            scope: DriveGrantedScopes::READONLY,
2605        }
2606    }
2607
2608    #[tokio::test]
2609    async fn access_token_refreshes_on_first_call() {
2610        let server = wiremock::MockServer::start().await;
2611        wiremock::Mock::given(wiremock::matchers::method("POST"))
2612            .respond_with(
2613                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2614                    "access_token": "at-first",
2615                    "expires_in": 3600,
2616                })),
2617            )
2618            .expect(1)
2619            .mount(&server)
2620            .await;
2621
2622        let session = DriveSession::new_with_token_endpoint(
2623            reqwest::Client::new(),
2624            &test_credentials(),
2625            &server.uri(),
2626        );
2627        let token = session.access_token().await.unwrap();
2628        assert_eq!(token.expose_secret(), "at-first");
2629    }
2630
2631    #[tokio::test]
2632    async fn access_token_reuses_cached_token_within_skew_window() {
2633        let server = wiremock::MockServer::start().await;
2634        wiremock::Mock::given(wiremock::matchers::method("POST"))
2635            .respond_with(
2636                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2637                    "access_token": "at-cached",
2638                    "expires_in": 3600,
2639                })),
2640            )
2641            .expect(1)
2642            .mount(&server)
2643            .await;
2644
2645        let session = DriveSession::new_with_token_endpoint(
2646            reqwest::Client::new(),
2647            &test_credentials(),
2648            &server.uri(),
2649        );
2650        let first = session.access_token().await.unwrap();
2651        let second = session.access_token().await.unwrap();
2652        assert_eq!(first, second);
2653    }
2654
2655    #[tokio::test]
2656    async fn access_token_proactively_refreshes_when_within_skew_window() {
2657        let server = wiremock::MockServer::start().await;
2658        wiremock::Mock::given(wiremock::matchers::method("POST"))
2659            .respond_with(
2660                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2661                    "access_token": "at-short",
2662                    // Less than REFRESH_SKEW (60s), so the second call also refreshes.
2663                    "expires_in": 30,
2664                })),
2665            )
2666            .expect(2)
2667            .mount(&server)
2668            .await;
2669
2670        let session = DriveSession::new_with_token_endpoint(
2671            reqwest::Client::new(),
2672            &test_credentials(),
2673            &server.uri(),
2674        );
2675        session.access_token().await.unwrap();
2676        session.access_token().await.unwrap();
2677    }
2678
2679    #[tokio::test]
2680    async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2681        let server = wiremock::MockServer::start().await;
2682        wiremock::Mock::given(wiremock::matchers::method("POST"))
2683            .respond_with(
2684                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2685                    "access_token": "at-overflow",
2686                    // Unvalidated, this overflows both TimeDelta::seconds and
2687                    // the subsequent DateTime<Utc> addition (#1531).
2688                    "expires_in": i64::MAX,
2689                })),
2690            )
2691            .expect(1)
2692            .mount(&server)
2693            .await;
2694
2695        let session = DriveSession::new_with_token_endpoint(
2696            reqwest::Client::new(),
2697            &test_credentials(),
2698            &server.uri(),
2699        );
2700        let token = session.access_token().await.unwrap();
2701        assert_eq!(token.expose_secret(), "at-overflow");
2702    }
2703
2704    #[tokio::test]
2705    async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2706        let server = wiremock::MockServer::start().await;
2707        wiremock::Mock::given(wiremock::matchers::method("POST"))
2708            .respond_with(
2709                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2710                    "access_token": "at-negative",
2711                    "expires_in": -3600,
2712                })),
2713            )
2714            // Negative expires_in clamps to 0, so the token is already
2715            // stale and the second call refreshes again.
2716            .expect(2)
2717            .mount(&server)
2718            .await;
2719
2720        let session = DriveSession::new_with_token_endpoint(
2721            reqwest::Client::new(),
2722            &test_credentials(),
2723            &server.uri(),
2724        );
2725        session.access_token().await.unwrap();
2726        session.access_token().await.unwrap();
2727    }
2728
2729    #[tokio::test]
2730    async fn force_refresh_concurrent_callers_do_not_stampede() {
2731        let server = wiremock::MockServer::start().await;
2732        wiremock::Mock::given(wiremock::matchers::method("POST"))
2733            .respond_with(
2734                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2735                    "access_token": "at-bootstrap",
2736                    "expires_in": 3600,
2737                })),
2738            )
2739            .up_to_n_times(1)
2740            .with_priority(1)
2741            .mount(&server)
2742            .await;
2743        wiremock::Mock::given(wiremock::matchers::method("POST"))
2744            .respond_with(
2745                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2746                    "access_token": "at-refreshed",
2747                    "expires_in": 3600,
2748                })),
2749            )
2750            .expect(1)
2751            .with_priority(2)
2752            .mount(&server)
2753            .await;
2754
2755        let session = DriveSession::new_with_token_endpoint(
2756            reqwest::Client::new(),
2757            &test_credentials(),
2758            &server.uri(),
2759        );
2760        let bootstrapped = session.access_token().await.unwrap();
2761        assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2762
2763        let (a, b) = tokio::join!(
2764            session.force_refresh(&bootstrapped),
2765            session.force_refresh(&bootstrapped)
2766        );
2767        let a = a.unwrap();
2768        let b = b.unwrap();
2769        assert_eq!(a, b);
2770        assert_eq!(a.expose_secret(), "at-refreshed");
2771    }
2772
2773    #[tokio::test]
2774    async fn force_refresh_skips_network_call_when_token_already_rotated() {
2775        let server = wiremock::MockServer::start().await;
2776        wiremock::Mock::given(wiremock::matchers::method("POST"))
2777            .respond_with(
2778                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2779                    "access_token": "at-a",
2780                    "expires_in": 3600,
2781                })),
2782            )
2783            .up_to_n_times(1)
2784            .with_priority(1)
2785            .mount(&server)
2786            .await;
2787        wiremock::Mock::given(wiremock::matchers::method("POST"))
2788            .respond_with(
2789                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2790                    "access_token": "at-b",
2791                    "expires_in": 3600,
2792                })),
2793            )
2794            .expect(1)
2795            .with_priority(2)
2796            .mount(&server)
2797            .await;
2798
2799        let session = DriveSession::new_with_token_endpoint(
2800            reqwest::Client::new(),
2801            &test_credentials(),
2802            &server.uri(),
2803        );
2804        let stale = Secret::new("at-never-issued");
2805        let bootstrapped = session.access_token().await.unwrap();
2806        assert_eq!(bootstrapped.expose_secret(), "at-a");
2807
2808        // `stale` was never the live token, so this should reuse the current
2809        // one without an extra network call.
2810        let result = session.force_refresh(&stale).await.unwrap();
2811        assert_eq!(result, bootstrapped);
2812
2813        // A real force_refresh against the actual current token does POST.
2814        let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2815        assert_eq!(refreshed.expose_secret(), "at-b");
2816    }
2817
2818    // ── Secret non-leakage ───────────────────────────────────────────
2819
2820    #[test]
2821    fn drive_credentials_debug_redacts_client_secret_and_refresh_token() {
2822        let creds = DriveCredentials {
2823            client_id: "client-visible".to_string(),
2824            client_secret: "sekret-client-secret".into(),
2825            refresh_token: "sekret-refresh-token".into(),
2826            scope: DriveGrantedScopes::READONLY,
2827        };
2828        let debug = format!("{creds:?}");
2829        assert!(debug.contains("DriveCredentials"));
2830        assert!(debug.contains("client-visible"));
2831        assert!(!debug.contains("sekret-client-secret"));
2832        assert!(!debug.contains("sekret-refresh-token"));
2833        assert!(debug.contains("client_secret: <redacted>"));
2834        assert!(debug.contains("refresh_token: <redacted>"));
2835    }
2836
2837    #[test]
2838    fn drive_auth_status_yaml_serialization_contains_no_secret_values() {
2839        let env = crate::test_support::env::MapEnv::new()
2840            .with(DRIVE_CLIENT_ID, "client-id-value")
2841            .with(DRIVE_CLIENT_SECRET, "sekret-do-not-leak")
2842            .with(DRIVE_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2843            .with(DRIVE_SCOPE, SCOPE_READONLY);
2844        let status = status_with(&env);
2845        let yaml = serde_yaml::to_string(&status).unwrap();
2846        assert!(!yaml.contains("sekret-do-not-leak"));
2847        assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2848    }
2849
2850    // ── Env-DI boundary tests ────────────────────────────────────────
2851
2852    use crate::test_support::env::MapEnv;
2853
2854    #[test]
2855    fn refresh_token_can_come_from_a_file() {
2856        let (_dir, path) = crate::test_support::env::secret_file("rt-from-file\n");
2857        let env = MapEnv::new()
2858            .with(DRIVE_CLIENT_ID, "id")
2859            .with(DRIVE_CLIENT_SECRET, "secret")
2860            .with("DRIVE_REFRESH_TOKEN_FILE", &path);
2861        let creds = load_credentials_with(&env).unwrap();
2862        assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
2863        assert!(status_with(&env).has_refresh_token);
2864    }
2865
2866    #[test]
2867    fn status_reports_all_false_when_nothing_configured() {
2868        let status = status_with(&MapEnv::new());
2869        assert!(!status.has_client_id);
2870        assert!(!status.has_client_secret);
2871        assert!(!status.has_refresh_token);
2872        assert_eq!(status.scope, None);
2873    }
2874
2875    #[test]
2876    fn status_reports_scope_when_present() {
2877        let env = MapEnv::new().with(DRIVE_SCOPE, SCOPE_READONLY);
2878        let status = status_with(&env);
2879        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2880    }
2881
2882    #[test]
2883    fn load_credentials_errors_when_client_id_missing() {
2884        let env = MapEnv::new()
2885            .with(DRIVE_CLIENT_SECRET, "s")
2886            .with(DRIVE_REFRESH_TOKEN, "r");
2887        let err = load_credentials_with(&env).unwrap_err();
2888        assert!(err.to_string().contains("not configured"));
2889    }
2890
2891    #[test]
2892    fn load_credentials_errors_when_client_secret_missing() {
2893        let env = MapEnv::new()
2894            .with(DRIVE_CLIENT_ID, "c")
2895            .with(DRIVE_REFRESH_TOKEN, "r");
2896        assert!(load_credentials_with(&env).is_err());
2897    }
2898
2899    #[test]
2900    fn load_credentials_errors_when_refresh_token_missing() {
2901        let env = MapEnv::new()
2902            .with(DRIVE_CLIENT_ID, "c")
2903            .with(DRIVE_CLIENT_SECRET, "s");
2904        assert!(load_credentials_with(&env).is_err());
2905    }
2906
2907    #[test]
2908    fn load_credentials_succeeds_with_all_three_present() {
2909        let env = MapEnv::new()
2910            .with(DRIVE_CLIENT_ID, "c")
2911            .with(DRIVE_CLIENT_SECRET, "s")
2912            .with(DRIVE_REFRESH_TOKEN, "r");
2913        let creds = load_credentials_with(&env).unwrap();
2914        assert_eq!(creds.client_id, "c");
2915        // No DRIVE_SCOPE set: degrades to an all-false scope set (not the
2916        // old ReadOnly default) — scope is never used to gate a real API
2917        // call client-side, only status display, so this is a safe,
2918        // strictly more conservative fallback (issue #1574).
2919        assert_eq!(creds.scope, DriveGrantedScopes::default());
2920    }
2921
2922    /// Save + remove round-trip against injected settings-file paths — no
2923    /// `HOME` mutation, so the test needs no lock.
2924    #[test]
2925    fn save_then_remove_round_trip() {
2926        // ── Part 1: creates file from scratch ──────────────────────
2927        {
2928            let temp_dir = {
2929                std::fs::create_dir_all("tmp").ok();
2930                tempfile::TempDir::new_in("tmp").unwrap()
2931            };
2932            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2933
2934            let creds = DriveCredentials {
2935                client_id: "client-1".to_string(),
2936                client_secret: "secret-1".into(),
2937                refresh_token: "refresh-1".into(),
2938                scope: DriveGrantedScopes::READONLY,
2939            };
2940            save_credentials_to(&settings_path, None, &creds).unwrap();
2941
2942            assert!(settings_path.exists());
2943            let val: serde_json::Value =
2944                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2945            assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "client-1");
2946            assert_eq!(val["env"]["DRIVE_CLIENT_SECRET"], "secret-1");
2947            assert_eq!(val["env"]["DRIVE_REFRESH_TOKEN"], "refresh-1");
2948            assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2949
2950            #[cfg(unix)]
2951            {
2952                use std::os::unix::fs::PermissionsExt;
2953                let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2954                assert_eq!(mode & 0o777, 0o600);
2955            }
2956        }
2957
2958        // ── Part 2: merges into existing settings ──────────────────
2959        {
2960            let temp_dir = {
2961                std::fs::create_dir_all("tmp").ok();
2962                tempfile::TempDir::new_in("tmp").unwrap()
2963            };
2964            let omni_dir = temp_dir.path().join(".omni-dev");
2965            fs::create_dir_all(&omni_dir).unwrap();
2966            let settings_path = omni_dir.join("settings.json");
2967            fs::write(
2968                &settings_path,
2969                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2970            )
2971            .unwrap();
2972
2973            let creds = DriveCredentials {
2974                client_id: "client-2".to_string(),
2975                client_secret: "secret-2".into(),
2976                refresh_token: "refresh-2".into(),
2977                scope: DriveGrantedScopes::READONLY,
2978            };
2979            save_credentials_to(&settings_path, None, &creds).unwrap();
2980
2981            let val: serde_json::Value =
2982                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2983            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2984            assert_eq!(val["extra"], true);
2985            assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2986        }
2987
2988        // ── Part 3: remove clears the four keys, preserves others ──
2989        {
2990            let temp_dir = {
2991                std::fs::create_dir_all("tmp").ok();
2992                tempfile::TempDir::new_in("tmp").unwrap()
2993            };
2994            let omni_dir = temp_dir.path().join(".omni-dev");
2995            fs::create_dir_all(&omni_dir).unwrap();
2996            let settings_path = omni_dir.join("settings.json");
2997            fs::write(
2998                &settings_path,
2999                r#"{"env": {
3000                    "DRIVE_CLIENT_ID": "a",
3001                    "DRIVE_CLIENT_SECRET": "b",
3002                    "DRIVE_REFRESH_TOKEN": "c",
3003                    "DRIVE_SCOPE": "d",
3004                    "OTHER_KEY": "keep"
3005                }}"#,
3006            )
3007            .unwrap();
3008
3009            let removed = remove_credentials_at(&settings_path, None).unwrap();
3010            assert!(removed);
3011
3012            let val: serde_json::Value =
3013                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3014            assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3015            assert!(val["env"].get("DRIVE_CLIENT_SECRET").is_none());
3016            assert!(val["env"].get("DRIVE_REFRESH_TOKEN").is_none());
3017            assert!(val["env"].get("DRIVE_SCOPE").is_none());
3018            assert_eq!(val["env"]["OTHER_KEY"], "keep");
3019        }
3020
3021        // ── Part 4: remove returns false when nothing to remove ────
3022        {
3023            let temp_dir = {
3024                std::fs::create_dir_all("tmp").ok();
3025                tempfile::TempDir::new_in("tmp").unwrap()
3026            };
3027            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
3028            let removed = remove_credentials_at(&settings_path, None).unwrap();
3029            assert!(!removed);
3030        }
3031    }
3032
3033    /// Save + remove round-trip against a profile-targeted env map.
3034    #[test]
3035    fn save_then_remove_round_trip_in_profile() {
3036        let temp_dir = {
3037            std::fs::create_dir_all("tmp").ok();
3038            tempfile::TempDir::new_in("tmp").unwrap()
3039        };
3040        let omni_dir = temp_dir.path().join(".omni-dev");
3041        fs::create_dir_all(&omni_dir).unwrap();
3042        let settings_path = omni_dir.join("settings.json");
3043        fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
3044
3045        let creds = DriveCredentials {
3046            client_id: "client-p".to_string(),
3047            client_secret: "secret-p".into(),
3048            refresh_token: "refresh-p".into(),
3049            scope: DriveGrantedScopes::READONLY,
3050        };
3051        save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
3052
3053        let val: serde_json::Value =
3054            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3055        assert_eq!(
3056            val["profiles"]["work"]["env"]["DRIVE_CLIENT_ID"],
3057            "client-p"
3058        );
3059        assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3060        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
3061
3062        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
3063        assert!(removed);
3064        let val: serde_json::Value =
3065            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3066        assert!(val["profiles"]["work"]["env"]
3067            .get("DRIVE_CLIENT_ID")
3068            .is_none());
3069
3070        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
3071        assert!(!removed);
3072    }
3073
3074    /// The production wrappers resolve `~/.omni-dev/settings.json` from
3075    /// `HOME` and the active profile from `OMNI_DEV_PROFILE`, so this one
3076    /// test must redirect both via [`crate::drive::test_support::EnvGuard`].
3077    #[test]
3078    fn save_and_remove_credentials_resolve_default_settings_path() {
3079        let guard = crate::drive::test_support::EnvGuard::take();
3080        let dir = guard.clear_credentials();
3081
3082        let creds = DriveCredentials {
3083            client_id: "wrapper-client".to_string(),
3084            client_secret: "wrapper-secret".into(),
3085            refresh_token: "wrapper-refresh".into(),
3086            scope: DriveGrantedScopes::READONLY,
3087        };
3088        save_credentials(&creds).unwrap();
3089
3090        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3091        let val: serde_json::Value =
3092            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3093        assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "wrapper-client");
3094
3095        assert!(remove_credentials().unwrap());
3096        let val: serde_json::Value =
3097            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3098        assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3099    }
3100
3101    // ── named-account dispatch (mirrors Gmail's issue #1500) ────────────
3102    //
3103    // These exercise the production `*_for` wrappers, so — like
3104    // `save_and_remove_credentials_resolve_default_settings_path` above —
3105    // they must redirect `HOME` via `EnvGuard`.
3106
3107    // ── named-account `_file` secrets (#2008) ────────────────────────────
3108
3109    /// Writes `contents` to `name` under `dir`, owner-only, and returns its
3110    /// absolute path as a settings value.
3111    fn owner_only_file(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
3112        let path = dir.path().join(name);
3113        std::fs::write(&path, contents).unwrap();
3114        #[cfg(unix)]
3115        {
3116            use std::os::unix::fs::PermissionsExt;
3117            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3118        }
3119        path.to_str().unwrap().to_string()
3120    }
3121
3122    fn named_settings(account: DriveAccountSettings) -> DriveSettings {
3123        DriveSettings {
3124            accounts: std::iter::once(("work".to_string(), account)).collect(),
3125            ..DriveSettings::default()
3126        }
3127    }
3128
3129    #[test]
3130    fn load_named_credentials_reads_secrets_from_their_files() {
3131        let dir = tempfile::tempdir().unwrap();
3132        let account = DriveAccountSettings {
3133            client_id: Some("id".to_string()),
3134            client_secret_file: Some(owner_only_file(&dir, "cs", "cs-from-file\n")),
3135            refresh_token_file: Some(owner_only_file(&dir, "rt", "rt-from-file\n")),
3136            ..DriveAccountSettings::default()
3137        };
3138        let creds = load_named_credentials(&named_settings(account), "work").unwrap();
3139        assert_eq!(creds.client_secret.expose_secret(), "cs-from-file");
3140        assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
3141    }
3142
3143    #[test]
3144    fn load_named_credentials_rejects_a_field_and_its_file_together() {
3145        let dir = tempfile::tempdir().unwrap();
3146        let account = DriveAccountSettings {
3147            client_id: Some("id".to_string()),
3148            client_secret: Some("cs".to_string()),
3149            refresh_token: Some("rt".to_string()),
3150            refresh_token_file: Some(owner_only_file(&dir, "rt", "rt\n")),
3151            ..DriveAccountSettings::default()
3152        };
3153        let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
3154        assert_eq!(
3155            err.to_string(),
3156            "both drive.accounts.work.refresh_token and drive.accounts.work.refresh_token_file \
3157             are set; set only one of them"
3158        );
3159    }
3160
3161    #[test]
3162    fn load_named_credentials_rejects_a_relative_secret_file() {
3163        let account = DriveAccountSettings {
3164            client_id: Some("id".to_string()),
3165            client_secret_file: Some("secrets/cs".to_string()),
3166            refresh_token: Some("rt".to_string()),
3167            ..DriveAccountSettings::default()
3168        };
3169        let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
3170        assert!(
3171            err.to_string()
3172                .contains("drive.accounts.work.client_secret_file must be an absolute path"),
3173            "{err}"
3174        );
3175    }
3176
3177    #[cfg(feature = "mcp")]
3178    #[test]
3179    fn status_from_named_counts_a_secret_file_as_present() {
3180        let account = DriveAccountSettings {
3181            client_id: Some("id".to_string()),
3182            client_secret_file: Some("/never/read".to_string()),
3183            refresh_token_file: Some("/never/read".to_string()),
3184            ..DriveAccountSettings::default()
3185        };
3186        let status = status_from_named(&named_settings(account), "work");
3187        assert!(status.has_client_secret);
3188        assert!(status.has_refresh_token);
3189    }
3190
3191    #[test]
3192    fn load_credentials_for_named_reads_from_drive_accounts() {
3193        let guard = crate::drive::test_support::EnvGuard::take();
3194        let dir = guard.clear_credentials();
3195        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3196        Settings::upsert_drive_account(
3197            &settings_path,
3198            "work",
3199            &[
3200                (
3201                    "client_id",
3202                    serde_json::Value::String("work-id".to_string()),
3203                ),
3204                (
3205                    "client_secret",
3206                    serde_json::Value::String("work-secret".to_string()),
3207                ),
3208                (
3209                    "refresh_token",
3210                    serde_json::Value::String("work-refresh".to_string()),
3211                ),
3212                (
3213                    "scope",
3214                    serde_json::Value::String(SCOPE_READONLY.to_string()),
3215                ),
3216            ],
3217        )
3218        .unwrap();
3219
3220        let creds = load_credentials_for(Some("work")).unwrap();
3221        assert_eq!(creds.client_id, "work-id");
3222        assert_eq!(creds.client_secret.expose_secret(), "work-secret");
3223        assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
3224        assert_eq!(creds.scope, DriveGrantedScopes::READONLY);
3225    }
3226
3227    #[test]
3228    fn load_credentials_for_unknown_named_account_errors() {
3229        let guard = crate::drive::test_support::EnvGuard::take();
3230        let dir = guard.clear_credentials();
3231        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3232        Settings::upsert_drive_account(
3233            &settings_path,
3234            "work",
3235            &[(
3236                "client_id",
3237                serde_json::Value::String("work-id".to_string()),
3238            )],
3239        )
3240        .unwrap();
3241
3242        let err = load_credentials_for(Some("bogus")).unwrap_err();
3243        assert!(err.to_string().contains("unknown Drive account 'bogus'"));
3244    }
3245
3246    #[test]
3247    fn load_credentials_for_falls_back_to_env_when_accounts_empty() {
3248        let guard = crate::drive::test_support::EnvGuard::take();
3249        let _dir = guard.clear_credentials();
3250        std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
3251        std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
3252        std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
3253
3254        let creds = load_credentials_for(None).unwrap();
3255        assert_eq!(creds.client_id, "literal-id");
3256    }
3257
3258    #[test]
3259    fn load_credentials_for_none_honors_ambient_account_env_var() {
3260        let guard = crate::drive::test_support::EnvGuard::take();
3261        let dir = guard.clear_credentials();
3262        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3263        Settings::upsert_drive_account(
3264            &settings_path,
3265            "work",
3266            &[
3267                (
3268                    "client_id",
3269                    serde_json::Value::String("work-id".to_string()),
3270                ),
3271                (
3272                    "client_secret",
3273                    serde_json::Value::String("work-secret".to_string()),
3274                ),
3275                (
3276                    "refresh_token",
3277                    serde_json::Value::String("work-refresh".to_string()),
3278                ),
3279            ],
3280        )
3281        .unwrap();
3282        std::env::set_var(account::DRIVE_ACCOUNT_ENV, "work");
3283
3284        let creds = load_credentials_for(None).unwrap();
3285        assert_eq!(creds.client_id, "work-id");
3286    }
3287
3288    #[test]
3289    fn remove_credentials_for_named_removes_whole_account() {
3290        let guard = crate::drive::test_support::EnvGuard::take();
3291        let dir = guard.clear_credentials();
3292        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3293        Settings::upsert_drive_account(
3294            &settings_path,
3295            "work",
3296            &[("client_id", serde_json::Value::String("id".to_string()))],
3297        )
3298        .unwrap();
3299
3300        assert!(remove_credentials_for(Some("work")).unwrap());
3301        let val: serde_json::Value =
3302            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3303        assert!(val["drive"]["accounts"].get("work").is_none());
3304    }
3305
3306    #[cfg(feature = "mcp")]
3307    #[test]
3308    fn status_for_named_reports_presence_from_account() {
3309        let guard = crate::drive::test_support::EnvGuard::take();
3310        let dir = guard.clear_credentials();
3311        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3312        Settings::upsert_drive_account(
3313            &settings_path,
3314            "work",
3315            &[
3316                ("client_id", serde_json::Value::String("id".to_string())),
3317                (
3318                    "scope",
3319                    serde_json::Value::String(SCOPE_READONLY.to_string()),
3320                ),
3321            ],
3322        )
3323        .unwrap();
3324
3325        let status = status_for(Some("work")).unwrap();
3326        assert!(status.has_client_id);
3327        assert!(!status.has_client_secret);
3328        assert!(!status.has_refresh_token);
3329        assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
3330    }
3331
3332    #[cfg(feature = "mcp")]
3333    #[test]
3334    fn status_for_unconfigured_matches_status_with_when_accounts_empty() {
3335        let guard = crate::drive::test_support::EnvGuard::take();
3336        let _dir = guard.clear_credentials();
3337        std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
3338
3339        let status = status_for(None).unwrap();
3340        assert!(status.has_client_id);
3341        assert!(!status.has_refresh_token);
3342    }
3343
3344    #[test]
3345    fn record_account_email_writes_email_address_only() {
3346        let guard = crate::drive::test_support::EnvGuard::take();
3347        let dir = guard.clear_credentials();
3348        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3349        Settings::upsert_drive_account(
3350            &settings_path,
3351            "work",
3352            &[("client_id", serde_json::Value::String("id".to_string()))],
3353        )
3354        .unwrap();
3355
3356        record_account_email("work", "alice@work.com").unwrap();
3357
3358        let val: serde_json::Value =
3359            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3360        assert_eq!(
3361            val["drive"]["accounts"]["work"]["email_address"],
3362            "alice@work.com"
3363        );
3364        assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
3365    }
3366
3367    #[test]
3368    fn record_account_email_does_not_overwrite_an_existing_value() {
3369        let guard = crate::drive::test_support::EnvGuard::take();
3370        let dir = guard.clear_credentials();
3371        let settings_path = dir.path().join(".omni-dev").join("settings.json");
3372        Settings::upsert_drive_account(
3373            &settings_path,
3374            "work",
3375            &[(
3376                "email_address",
3377                serde_json::Value::String("manually-set@work.com".to_string()),
3378            )],
3379        )
3380        .unwrap();
3381
3382        record_account_email("work", "alice@work.com").unwrap();
3383
3384        let val: serde_json::Value =
3385            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3386        assert_eq!(
3387            val["drive"]["accounts"]["work"]["email_address"],
3388            "manually-set@work.com"
3389        );
3390    }
3391
3392    /// The empty-`drive.accounts` fallback path (mirrors Gmail's issue
3393    /// #1500 zero-migration guarantee): with `drive.accounts` empty, the
3394    /// account-aware `_for(None, ...)` wrappers must behave byte-identically
3395    /// to the direct env/settings wrappers they sit beside. Both sandboxes
3396    /// are seeded via the same unchanged [`save_credentials`] (no
3397    /// account-aware save wrapper exists — `login_for` persists directly,
3398    /// since a resolve-then-save round trip would reject the very name
3399    /// being created), so this isolates `load`/`remove`.
3400    #[test]
3401    fn accounts_empty_load_remove_byte_identical_via_direct_and_for_wrappers() {
3402        let guard = crate::drive::test_support::EnvGuard::take();
3403        let creds = DriveCredentials {
3404            client_id: "id".to_string(),
3405            client_secret: "secret".into(),
3406            refresh_token: "refresh".into(),
3407            scope: DriveGrantedScopes::READONLY,
3408        };
3409
3410        let dir_direct = guard.clear_credentials();
3411        save_credentials(&creds).unwrap();
3412        let direct_written =
3413            fs::read_to_string(dir_direct.path().join(".omni-dev").join("settings.json")).unwrap();
3414        let direct_loaded = load_credentials().unwrap();
3415        let direct_removed = remove_credentials().unwrap();
3416
3417        let dir_for = guard.clear_credentials();
3418        save_credentials(&creds).unwrap();
3419        let for_written =
3420            fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
3421        let for_loaded = load_credentials_for(None).unwrap();
3422        let for_removed = remove_credentials_for(None).unwrap();
3423
3424        assert_eq!(direct_written, for_written);
3425        assert_eq!(direct_loaded.client_id, for_loaded.client_id);
3426        assert_eq!(
3427            direct_loaded.client_secret.expose_secret(),
3428            for_loaded.client_secret.expose_secret()
3429        );
3430        assert_eq!(direct_loaded.scope, for_loaded.scope);
3431        assert_eq!(direct_removed, for_removed);
3432    }
3433}