Skip to main content

omni_dev/drive/
auth.rs

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