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("Drive API request failed: HTTP {status}: {body}")]
50    ApiRequestFailed {
51        /// HTTP status code.
52        status: u16,
53        /// Response body text (or an extracted `error.message`/`reason`
54        /// summary when the body is Drive's JSON error envelope).
55        body: String,
56        /// The `error.errors[0].reason` field from Drive's JSON error
57        /// envelope, if the body parsed as that shape. Populated directly by
58        /// `DriveClient::response_to_error` — not re-derived from `body`.
59        reason: Option<String>,
60    },
61
62    /// The OAuth callback's `state` did not match the value generated at the
63    /// start of login.
64    #[error(
65        "OAuth state mismatch: the browser callback did not present the expected state \
66         value; aborting login for safety"
67    )]
68    StateMismatch,
69
70    /// Google's `?error=` redirect (e.g. the user clicked "Cancel").
71    #[error("Google denied the authorization request: {0}")]
72    AuthorizationDenied(String),
73
74    /// No browser callback arrived within the timeout.
75    #[error(
76        "Timed out after {0}s waiting for the browser sign-in callback; re-run \
77         `omni-dev drive auth login`"
78    )]
79    CallbackTimeout(u64),
80
81    /// The browser's callback request could not be parsed.
82    #[error(
83        "The browser's sign-in callback was malformed or missing the `code`/`state` parameters"
84    )]
85    MalformedCallback,
86
87    /// Google rejected a code exchange or refresh with `invalid_grant`.
88    #[error("Google rejected the request (invalid_grant): {0}")]
89    InvalidGrant(GrantContext),
90
91    /// Google's token response was missing a required field.
92    #[error("Drive OAuth token response was malformed: missing `{0}`")]
93    MalformedTokenResponse(&'static str),
94
95    /// Google's token response carried no `drive.readonly` scope at all —
96    /// e.g. the Drive permission was left unticked on the consent screen.
97    #[error(
98        "Google did not grant the drive.readonly scope (received: {0}).\n  On the consent \
99         screen, tick the Drive permission — restricted scopes are not granted by default. \
100         Re-run `omni-dev drive auth login`."
101    )]
102    NoScopeGranted(String),
103
104    /// The configured browser launch command was invalid.
105    #[error("Invalid browser launch command: {0}")]
106    InvalidBrowserCommand(String),
107}
108
109impl DriveError {
110    /// Builds an [`AuthorizationDenied`](Self::AuthorizationDenied) from
111    /// Google's `error`/`error_description` redirect parameters.
112    ///
113    /// Kept as a constructor (not a conditional format string) so the
114    /// "does an optional description exist" branching lives in one place
115    /// instead of inside the `#[error(...)]` macro, matching this
116    /// codebase's convention of plain field interpolation in
117    /// `#[error(...)]` attributes.
118    #[must_use]
119    pub(crate) fn authorization_denied(reason: &str, description: Option<&str>) -> Self {
120        let detail = match description {
121            Some(d) if !d.is_empty() => format!("{reason} ({d})"),
122            _ => reason.to_string(),
123        };
124        Self::AuthorizationDenied(detail)
125    }
126
127    /// The Drive-specific `reason` field of an
128    /// [`ApiRequestFailed`](Self::ApiRequestFailed), if
129    /// `DriveClient::response_to_error` found one.
130    ///
131    /// Unused for now: Gmail's twin (`GmailError::reason`) is used by
132    /// `gmail sync`'s reconciliation engine, which Drive has no equivalent
133    /// of (explicitly out of scope) — reserved for a future error-reason
134    /// sensitive consumer. Narrow allow here rather than a crate-wide one
135    /// (removed in #1524).
136    #[allow(dead_code)]
137    pub(crate) fn reason(&self) -> Option<&str> {
138        match self {
139            Self::ApiRequestFailed { reason, .. } => reason.as_deref(),
140            _ => None,
141        }
142    }
143}
144
145#[cfg(test)]
146#[allow(clippy::unwrap_used, clippy::expect_used)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn credentials_not_found_display() {
152        let err = DriveError::CredentialsNotFound;
153        assert!(err.to_string().contains("not configured"));
154        assert!(err.to_string().contains("drive auth login"));
155    }
156
157    #[test]
158    fn api_request_failed_display() {
159        let err = DriveError::ApiRequestFailed {
160            status: 403,
161            body: "insufficientPermissions".to_string(),
162            reason: None,
163        };
164        let msg = err.to_string();
165        assert!(msg.contains("403"));
166        assert!(msg.contains("insufficientPermissions"));
167    }
168
169    #[test]
170    fn state_mismatch_display_mentions_state() {
171        assert!(DriveError::StateMismatch.to_string().contains("state"));
172    }
173
174    #[test]
175    fn reason_returns_the_structured_field() {
176        let err = DriveError::ApiRequestFailed {
177            status: 404,
178            body: "Not Found (reason: notFound)".to_string(),
179            reason: Some("notFound".to_string()),
180        };
181        assert_eq!(err.reason(), Some("notFound"));
182    }
183
184    #[test]
185    fn reason_is_none_when_the_structured_field_is_absent() {
186        let err = DriveError::ApiRequestFailed {
187            status: 500,
188            body: "Internal Server Error".to_string(),
189            reason: None,
190        };
191        assert_eq!(err.reason(), None);
192    }
193
194    #[test]
195    fn reason_ignores_a_reason_like_substring_embedded_in_the_body() {
196        let err = DriveError::ApiRequestFailed {
197            status: 400,
198            body: "Message already explains itself (reason: not the real one)".to_string(),
199            reason: None,
200        };
201        assert_eq!(err.reason(), None);
202    }
203
204    #[test]
205    fn reason_is_none_for_non_api_request_failed_variants() {
206        assert_eq!(DriveError::StateMismatch.reason(), None);
207    }
208
209    #[test]
210    fn authorization_denied_includes_description_when_present() {
211        let err = DriveError::authorization_denied("access_denied", Some("user declined"));
212        let msg = err.to_string();
213        assert!(msg.contains("access_denied"));
214        assert!(msg.contains("user declined"));
215        assert!(msg.contains('('));
216    }
217
218    #[test]
219    fn authorization_denied_omits_parens_when_absent() {
220        let err = DriveError::authorization_denied("access_denied", None);
221        let msg = err.to_string();
222        assert!(msg.contains("access_denied"));
223        assert!(!msg.contains('('));
224    }
225
226    #[test]
227    fn authorization_denied_omits_parens_when_description_empty() {
228        let err = DriveError::authorization_denied("access_denied", Some(""));
229        let msg = err.to_string();
230        assert!(!msg.contains('('));
231    }
232
233    #[test]
234    fn callback_timeout_display_includes_seconds() {
235        let err = DriveError::CallbackTimeout(120);
236        assert!(err.to_string().contains("120"));
237    }
238
239    #[test]
240    fn malformed_callback_display() {
241        assert!(DriveError::MalformedCallback
242            .to_string()
243            .to_lowercase()
244            .contains("malformed"));
245    }
246
247    #[test]
248    fn invalid_grant_code_exchange_display_mentions_pkce_and_expired_code() {
249        let err = DriveError::InvalidGrant(GrantContext::CodeExchange);
250        let msg = err.to_string();
251        assert!(msg.contains("PKCE"));
252        assert!(msg.contains("expired"));
253        assert!(msg.contains("auth login"));
254    }
255
256    #[test]
257    fn invalid_grant_refresh_display_mentions_7_days_and_testing_mode() {
258        let err = DriveError::InvalidGrant(GrantContext::Refresh);
259        let msg = err.to_string();
260        assert!(msg.contains("7 days"));
261        assert!(msg.contains("Testing"));
262        assert!(msg.contains("auth login"));
263    }
264
265    #[test]
266    fn grant_context_variants_produce_distinct_messages() {
267        let code = GrantContext::CodeExchange.to_string();
268        let refresh = GrantContext::Refresh.to_string();
269        assert_ne!(code, refresh);
270    }
271
272    #[test]
273    fn malformed_token_response_names_the_missing_field() {
274        let err = DriveError::MalformedTokenResponse("refresh_token");
275        assert!(err.to_string().contains("refresh_token"));
276    }
277
278    #[test]
279    fn invalid_browser_command_display_includes_detail() {
280        let err = DriveError::InvalidBrowserCommand("empty command".to_string());
281        assert!(err.to_string().contains("empty command"));
282    }
283
284    #[test]
285    fn no_scope_granted_display_names_the_received_scopes() {
286        let err = DriveError::NoScopeGranted("openid, email, profile".to_string());
287        let msg = err.to_string();
288        assert!(msg.contains("openid, email, profile"));
289        assert!(msg.contains("consent screen"));
290        assert!(msg.contains("auth login"));
291    }
292}