Skip to main content

omni_dev/drive/
error.rs

1//! Error types for Drive operations.
2
3use std::fmt;
4
5use thiserror::Error;
6
7/// Which OAuth2 grant produced Google's `invalid_grant` response.
8///
9/// Google's response body is identical for both causes, so the
10/// distinguishing message is chosen from *which call* got the error, not
11/// from anything in the response body itself.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum GrantContext {
14    /// The initial `authorization_code` → token exchange.
15    CodeExchange,
16    /// A `refresh_token` → access-token renewal.
17    Refresh,
18}
19
20impl fmt::Display for GrantContext {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::CodeExchange => write!(
24                f,
25                "the authorization code was invalid, already used, expired (codes are \
26                 single-use and valid only a few minutes), or the PKCE code_verifier did not \
27                 match the code_challenge sent at the start of login. Run \
28                 `omni-dev drive auth login` again."
29            ),
30            Self::Refresh => write!(
31                f,
32                "this almost always means either (1) your Drive OAuth client is in \"Testing\" \
33                 publishing status, where refresh tokens expire after 7 days — publish it to \
34                 \"In production\" in Google Cloud Console to avoid this, or (2) access was \
35                 revoked. Run `omni-dev drive auth login` again to re-authenticate."
36            ),
37        }
38    }
39}
40
41/// Errors that can occur during Drive operations.
42#[derive(Error, Debug)]
43pub enum DriveError {
44    /// Drive credentials are not configured.
45    #[error("Drive credentials not configured. Run `omni-dev drive auth login`")]
46    CredentialsNotFound,
47
48    /// A Drive API request failed.
49    #[error("{api} API request failed: HTTP {status}: {body}")]
50    ApiRequestFailed {
51        /// Which Google API produced this — `"Drive"` or `"Sheets"`.
52        ///
53        /// Carried rather than hardcoded because both APIs share one
54        /// transport and one `DriveError`: a Sheets 403 that announced
55        /// itself as a *Drive* failure sent the reader to the wrong API's
56        /// docs, and that is the message a new user is most likely to hit
57        /// (a `drive.file` grant cannot write a browser-created Sheet).
58        api: &'static str,
59        /// HTTP status code.
60        status: u16,
61        /// Response body text (or an extracted `error.message`/`reason`
62        /// summary when the body is Drive's JSON error envelope).
63        body: String,
64        /// The `error.errors[0].reason` field from Drive's JSON error
65        /// envelope, if the body parsed as that shape. Populated directly by
66        /// `DriveClient::response_to_error` — not re-derived from `body`.
67        reason: Option<String>,
68    },
69
70    /// The OAuth callback's `state` did not match the value generated at the
71    /// start of login.
72    #[error(
73        "OAuth state mismatch: the browser callback did not present the expected state \
74         value; aborting login for safety"
75    )]
76    StateMismatch,
77
78    /// Google's `?error=` redirect (e.g. the user clicked "Cancel").
79    #[error("Google denied the authorization request: {0}")]
80    AuthorizationDenied(String),
81
82    /// No browser callback arrived within the timeout.
83    #[error(
84        "Timed out after {0}s waiting for the browser sign-in callback; re-run \
85         `omni-dev drive auth login`"
86    )]
87    CallbackTimeout(u64),
88
89    /// The browser's callback request could not be parsed.
90    #[error(
91        "The browser's sign-in callback was malformed or missing the `code`/`state` parameters"
92    )]
93    MalformedCallback,
94
95    /// Google rejected a code exchange or refresh with `invalid_grant`.
96    #[error("Google rejected the request (invalid_grant): {0}")]
97    InvalidGrant(GrantContext),
98
99    /// Google's token response was missing a required field.
100    #[error("Drive OAuth token response was malformed: missing `{0}`")]
101    MalformedTokenResponse(&'static str),
102
103    /// Google's token response carried no `drive.readonly` scope at all —
104    /// e.g. the Drive permission was left unticked on the consent screen.
105    #[error(
106        "Google did not grant the drive.readonly scope (received: {0}).\n  On the consent \
107         screen, tick the Drive permission — restricted scopes are not granted by default. \
108         Re-run `omni-dev drive auth login`."
109    )]
110    NoScopeGranted(String),
111
112    /// The configured browser launch command was invalid.
113    #[error("Invalid browser launch command: {0}")]
114    InvalidBrowserCommand(String),
115}
116
117impl DriveError {
118    /// Builds an [`AuthorizationDenied`](Self::AuthorizationDenied) from
119    /// Google's `error`/`error_description` redirect parameters.
120    ///
121    /// Kept as a constructor (not a conditional format string) so the
122    /// "does an optional description exist" branching lives in one place
123    /// instead of inside the `#[error(...)]` macro, matching this
124    /// codebase's convention of plain field interpolation in
125    /// `#[error(...)]` attributes.
126    #[must_use]
127    pub(crate) fn authorization_denied(reason: &str, description: Option<&str>) -> Self {
128        let detail = match description {
129            Some(d) if !d.is_empty() => format!("{reason} ({d})"),
130            _ => reason.to_string(),
131        };
132        Self::AuthorizationDenied(detail)
133    }
134
135    /// The Drive-specific `reason` field of an
136    /// [`ApiRequestFailed`](Self::ApiRequestFailed), if
137    /// `DriveClient::response_to_error` found one.
138    ///
139    /// Unused for now: Gmail's twin (`GmailError::reason`) is used by
140    /// `gmail sync`'s reconciliation engine, which Drive has no equivalent
141    /// of (explicitly out of scope) — reserved for a future error-reason
142    /// sensitive consumer. Narrow allow here rather than a crate-wide one
143    /// (removed in #1524).
144    #[allow(dead_code)]
145    pub(crate) fn reason(&self) -> Option<&str> {
146        match self {
147            Self::ApiRequestFailed { reason, .. } => reason.as_deref(),
148            _ => None,
149        }
150    }
151}
152
153#[cfg(test)]
154#[allow(clippy::unwrap_used, clippy::expect_used)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn credentials_not_found_display() {
160        let err = DriveError::CredentialsNotFound;
161        assert!(err.to_string().contains("not configured"));
162        assert!(err.to_string().contains("drive auth login"));
163    }
164
165    #[test]
166    fn api_request_failed_display() {
167        let err = DriveError::ApiRequestFailed {
168            api: "Drive",
169            status: 403,
170            body: "insufficientPermissions".to_string(),
171            reason: None,
172        };
173        let msg = err.to_string();
174        assert!(msg.contains("403"));
175        assert!(msg.contains("insufficientPermissions"));
176    }
177
178    #[test]
179    fn state_mismatch_display_mentions_state() {
180        assert!(DriveError::StateMismatch.to_string().contains("state"));
181    }
182
183    #[test]
184    fn reason_returns_the_structured_field() {
185        let err = DriveError::ApiRequestFailed {
186            api: "Drive",
187            status: 404,
188            body: "Not Found (reason: notFound)".to_string(),
189            reason: Some("notFound".to_string()),
190        };
191        assert_eq!(err.reason(), Some("notFound"));
192    }
193
194    #[test]
195    fn reason_is_none_when_the_structured_field_is_absent() {
196        let err = DriveError::ApiRequestFailed {
197            api: "Drive",
198            status: 500,
199            body: "Internal Server Error".to_string(),
200            reason: None,
201        };
202        assert_eq!(err.reason(), None);
203    }
204
205    #[test]
206    fn reason_ignores_a_reason_like_substring_embedded_in_the_body() {
207        let err = DriveError::ApiRequestFailed {
208            api: "Drive",
209            status: 400,
210            body: "Message already explains itself (reason: not the real one)".to_string(),
211            reason: None,
212        };
213        assert_eq!(err.reason(), None);
214    }
215
216    #[test]
217    fn reason_is_none_for_non_api_request_failed_variants() {
218        assert_eq!(DriveError::StateMismatch.reason(), None);
219    }
220
221    #[test]
222    fn authorization_denied_includes_description_when_present() {
223        let err = DriveError::authorization_denied("access_denied", Some("user declined"));
224        let msg = err.to_string();
225        assert!(msg.contains("access_denied"));
226        assert!(msg.contains("user declined"));
227        assert!(msg.contains('('));
228    }
229
230    #[test]
231    fn authorization_denied_omits_parens_when_absent() {
232        let err = DriveError::authorization_denied("access_denied", None);
233        let msg = err.to_string();
234        assert!(msg.contains("access_denied"));
235        assert!(!msg.contains('('));
236    }
237
238    #[test]
239    fn authorization_denied_omits_parens_when_description_empty() {
240        let err = DriveError::authorization_denied("access_denied", Some(""));
241        let msg = err.to_string();
242        assert!(!msg.contains('('));
243    }
244
245    #[test]
246    fn callback_timeout_display_includes_seconds() {
247        let err = DriveError::CallbackTimeout(120);
248        assert!(err.to_string().contains("120"));
249    }
250
251    #[test]
252    fn malformed_callback_display() {
253        assert!(DriveError::MalformedCallback
254            .to_string()
255            .to_lowercase()
256            .contains("malformed"));
257    }
258
259    #[test]
260    fn invalid_grant_code_exchange_display_mentions_pkce_and_expired_code() {
261        let err = DriveError::InvalidGrant(GrantContext::CodeExchange);
262        let msg = err.to_string();
263        assert!(msg.contains("PKCE"));
264        assert!(msg.contains("expired"));
265        assert!(msg.contains("auth login"));
266    }
267
268    #[test]
269    fn invalid_grant_refresh_display_mentions_7_days_and_testing_mode() {
270        let err = DriveError::InvalidGrant(GrantContext::Refresh);
271        let msg = err.to_string();
272        assert!(msg.contains("7 days"));
273        assert!(msg.contains("Testing"));
274        assert!(msg.contains("auth login"));
275    }
276
277    #[test]
278    fn grant_context_variants_produce_distinct_messages() {
279        let code = GrantContext::CodeExchange.to_string();
280        let refresh = GrantContext::Refresh.to_string();
281        assert_ne!(code, refresh);
282    }
283
284    #[test]
285    fn malformed_token_response_names_the_missing_field() {
286        let err = DriveError::MalformedTokenResponse("refresh_token");
287        assert!(err.to_string().contains("refresh_token"));
288    }
289
290    #[test]
291    fn invalid_browser_command_display_includes_detail() {
292        let err = DriveError::InvalidBrowserCommand("empty command".to_string());
293        assert!(err.to_string().contains("empty command"));
294    }
295
296    #[test]
297    fn no_scope_granted_display_names_the_received_scopes() {
298        let err = DriveError::NoScopeGranted("openid, email, profile".to_string());
299        let msg = err.to_string();
300        assert!(msg.contains("openid, email, profile"));
301        assert!(msg.contains("consent screen"));
302        assert!(msg.contains("auth login"));
303    }
304}