//! Crate-wide error type. use proton_sdk::api::HumanVerification; use thiserror::Error; use crate::control::ErrorKind; #[derive(Debug, Error)] pub enum Error { #[error("proton api: {0}")] Proton(#[from] proton_sdk::ProtonError), #[error("keyring: {0}")] Keyring(#[from] keyring_core::Error), #[error("serialization: {0}")] Serde(#[from] serde_json::Error), #[error("io: {0}")] Io(#[from] std::io::Error), #[error("database: {0}")] Db(#[from] rusqlite::Error), /// No persisted session found — caller must run an interactive login. #[error("not logged in (no saved session)")] NotLoggedIn, /// The stored session can no longer unlock the account key chain: reading /// the key salts needs the `locked` scope, which a refreshed access token /// does not have. Only a fresh password login restores it. #[error("saved session can no longer unlock your keys — run `pdfs login` again")] ReloginRequired, /// The account has a separate mailbox password that has not been entered /// since login, so its keys cannot be unlocked. The fix is /// [`auth::unlock`](crate::auth::unlock), not a new login. #[error("account is locked — enter the mailbox password with `pdfs unlock`")] Locked, /// The mailbox password was checked against the account and is wrong. #[error("that mailbox password does not unlock your account")] WrongMailboxPassword, /// The login was gated behind human verification. Not a failure the user can /// fix by retrying: the challenge has to be solved and the login restarted /// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)). /// /// Carries the challenge rather than just a message so a front-end can /// present it; a front-end that cannot (no webview) still has prose to show. #[error("sign-in needs human verification — complete the CAPTCHA to continue")] HumanVerificationRequired(Box), #[error("{0}")] Other(String), } impl Error { /// Whether this is the local disk being full (`ENOSPC`), as opposed to any /// other I/O failure. /// /// Worth distinguishing because it is the one storage failure the user can /// act on, and because the caller's response differs: a full disk is met by /// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a /// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this /// compares the raw code. pub fn is_disk_full(&self) -> bool { match self { Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC), // SQLite reports it as its own code rather than surfacing the errno. Error::Db(rusqlite::Error::SqliteFailure(e, _)) => { e.code == rusqlite::ErrorCode::DiskFull } _ => false, } } } impl Error { /// Whether a control-socket round-trip gave up waiting for the reply: the /// daemon is running but stuck, as opposed to not running at all. pub fn is_timeout(&self) -> bool { matches!( self, Error::Io(e) if matches!( e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut ) ) } } pub type Result = std::result::Result; /// A failure on its way to a front-end: prose for the user, plus the /// [`ErrorKind`] the front-end actually branches on. /// /// This is what the daemon's request-serving methods return, as opposed to /// [`Error`] above, which is the plumbing error of the `pdfs-core` layer. The /// two meet at the `From` impls below: a `rusqlite` or `io` failure carries no /// information a user can act on, so it arrives here as /// [`ErrorKind::Internal`] and keeps its text only for the log. /// /// Classification happens where the failure is *understood* — at the API /// boundary that knows a 404 from a 403, at the path resolver that knows an /// `ENOENT` — never at the call site, which by then has only a string. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CoreError { pub kind: ErrorKind, pub message: String, } impl CoreError { pub fn new(kind: ErrorKind, message: impl Into) -> Self { Self { kind, message: message.into(), } } /// The API is unreachable. Carries fixed wording because there is only ever /// one thing to say about it. pub fn offline() -> Self { Self::new(ErrorKind::Offline, "the Proton Drive API is unreachable") } pub fn not_found(what: impl Into) -> Self { Self::new(ErrorKind::NotFound, what) } pub fn denied(what: impl Into) -> Self { Self::new(ErrorKind::Denied, what) } pub fn conflict(what: impl Into) -> Self { Self::new(ErrorKind::Conflict, what) } pub fn invalid(what: impl Into) -> Self { Self::new(ErrorKind::Invalid, what) } pub fn remote(what: impl Into) -> Self { Self::new(ErrorKind::Remote, what) } pub fn internal(what: impl Into) -> Self { Self::new(ErrorKind::Internal, what) } /// Classify a failure from an SDK call, prefixing `what` for the log. /// /// Takes `&dyn Error` rather than `&ProtonError` so it reads the same /// through the boxes the drain deals in, where the concrete type survives /// but the static one does not. /// /// A transport error, a timeout or a TLS failure is what being offline /// looks like from here, so it classifies as [`ErrorKind::Offline`] rather /// than [`ErrorKind::Remote`]: the request never reached Proton, and /// telling the user their connection is down beats telling them the /// server refused. The SDK's other failures say what went wrong instead /// (see [`proton_kind`]). /// /// Callers also hand it this crate's [`Error`] from the cache and the /// database, which is not a transport error at all. Taking those for /// "offline" told the user their connection was down when a pin failed on /// the database (`docs/BUGS.md` B266), so the source chain is searched for /// either type before falling back. pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self { Self::new(api_kind_of(e), format!("{what}: {e}")) } /// Prefix the message while keeping the classification. /// /// For adding the caller's context to an already-classified failure — the /// kind was decided by whoever understood the error and must survive being /// passed up. pub fn context(mut self, what: &str) -> Self { self.message = format!("{what}: {}", self.message); self } } impl std::fmt::Display for CoreError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) } } impl std::error::Error for CoreError {} impl From for CoreError { fn from(e: rusqlite::Error) -> Self { Self::internal(format!("database: {e}")) } } impl From for CoreError { fn from(e: std::io::Error) -> Self { Self::internal(format!("io: {e}")) } } impl From for CoreError { fn from(e: serde_json::Error) -> Self { Self::internal(format!("serialization: {e}")) } } impl From for CoreError { fn from(e: Error) -> Self { Self::new(core_kind(&e), e.to_string()) } } /// What this crate's [`Error`] means to a front-end. fn core_kind(e: &Error) -> ErrorKind { match e { Error::NotLoggedIn | Error::ReloginRequired | Error::Locked | Error::WrongMailboxPassword | Error::HumanVerificationRequired(_) => ErrorKind::Denied, Error::Proton(proton) => proton_kind(proton), _ => ErrorKind::Internal, } } /// The kind for [`CoreError::from_api`]: the first SDK or crate error in `e`'s /// source chain decides it. fn api_kind_of(e: &(dyn std::error::Error + 'static)) -> ErrorKind { let mut cause = Some(e); while let Some(err) = cause { if let Some(proton) = err.downcast_ref::() { return proton_kind(proton); } if let Some(core) = err.downcast_ref::() { return core_kind(core); } cause = err.source(); } // Not the SDK's at all: a timeout around the call, or the socket. ErrorKind::Offline } /// What a failed SDK call means to a front-end. /// /// Only a transport failure is the connection. The rest happened on this /// machine, and calling them "offline" sent the user to check a network that /// worked (issue #38: a photo upload on an account without a photo library /// read "You're offline"). fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind { use proton_sdk::ProtonError as P; match e { P::Api(api) => api_kind(api.code), P::Transport(_) => ErrorKind::Offline, // Proton answered, but not with anything this client can read. P::Serialization(_) => ErrorKind::Remote, // The SDK refused the call before sending it, such as an upload to a // photo library the account does not have. P::InvalidOperation(_) => ErrorKind::Invalid, P::Crypto(_) | P::KeysLocked => ErrorKind::Internal, } } /// What an API response code means to a front-end. /// /// The grouping is by *what the user can do about it*, which is what /// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a /// `NotEnoughPermissions` land together despite being different numbers, while /// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx. fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind { use proton_sdk::api::ResponseCode as C; match code { // The account may not do this, and repeating it will not help. C::Unauthorized | C::Forbidden | C::NotEnoughPermissions | C::NotEnoughPermissionsToGrantPermissions | C::InsufficientScope | C::IncorrectLoginCredentials | C::InvalidRefreshToken | C::AccountDeleted | C::AccountDisabled | C::NoActiveSubscription => ErrorKind::Denied, C::DoesNotExist => ErrorKind::NotFound, // The remote moved underneath the request; the caller has to decide. C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict, // The request was malformed or asks for something structurally // impossible. A bug in the caller, not a condition to retry. C::InvalidRequirements | C::InvalidValue | C::InvalidEncryptedIdFormat | C::InvalidApp | C::TooManyChildren | C::NestingTooDeep => ErrorKind::Invalid, C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => { ErrorKind::Quota } // Reached Proton, but it could not answer *now*. Retrying is exactly // the right response, so these are Offline rather than Remote — the // front-end already words that as "not right now". C::RequestTimeout | C::Timeout | C::TooManyRequests | C::ServiceUnavailable | C::Offline => ErrorKind::Offline, // Reached Proton and it refused for a reason we have no specific // handling for. Retryable, because an unchanged retry can legitimately // succeed against a transient server-side condition. _ => ErrorKind::Remote, } } /// Whether `e` says the session itself is gone: its refresh token was refused, /// or the account was deleted or disabled. /// /// Nothing but a new login helps then, and no retry will ever succeed, so a /// caller must not take it for a lost connection and wait for it to come back /// (issue #35: a revoked session was shown as "offline" for good). pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool { use proton_sdk::api::ResponseCode as C; match e { proton_sdk::ProtonError::Api(api) => { api.is_invalid_refresh_token() || matches!(api.code, C::AccountDeleted | C::AccountDisabled) } _ => false, } } /// Whether `e` is Proton's temporary block on a sign-in (code 2028). /// /// Its message says the app version is no longer supported, which is not what /// happened: the release is current and only waiting helps (issues #28, #39). /// A front-end must say that instead of passing the message through. pub fn sign_in_blocked(e: &proton_sdk::ProtonError) -> bool { matches!(e, proton_sdk::ProtonError::Api(api) if api.code == proton_sdk::api::ResponseCode::Blocked) } /// The result every request-serving method in the daemon returns. pub type CoreResult = std::result::Result; #[cfg(test)] mod tests { use super::*; use proton_sdk::api::ResponseCode as C; use proton_sdk::{ProtonApiError, ProtonError}; fn api(code: C) -> ProtonError { ProtonError::Api(ProtonApiError { code, http_status: 422, message: "nope".into(), details: None, }) } #[test] fn api_codes_classify_by_what_the_user_can_do() { assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied); assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied); assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound); assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict); assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid); assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota); assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline); // Nothing specific known about it, but Proton did answer. assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote); } #[test] fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() { assert!(session_revoked(&api(C::InvalidRefreshToken))); assert!(session_revoked(&api(C::AccountDeleted))); assert!(session_revoked(&api(C::AccountDisabled))); // One file the account may not touch says nothing about the session. assert!(!session_revoked(&api(C::Forbidden))); assert!(!session_revoked(&api(C::Timeout))); assert!(!session_revoked(&ProtonError::KeysLocked)); } #[test] fn only_code_2028_is_a_blocked_sign_in() { assert!(sign_in_blocked(&api(C::Blocked))); assert!(!sign_in_blocked(&api(C::OutdatedApp))); assert!(!sign_in_blocked(&api(C::TooManyRequests))); assert!(!sign_in_blocked(&ProtonError::KeysLocked)); } /// The regression this whole pass exists to prevent: before it, every SDK /// failure was hardcoded to `Remote`, so a 403 asked the user to retry a /// request that could never succeed. #[test] fn a_forbidden_response_is_not_offered_as_retryable() { let e = CoreError::from_api(&api(C::Forbidden), "rename"); assert_eq!(e.kind, ErrorKind::Denied); assert!(!e.kind.retryable()); assert!(e.message.starts_with("rename: ")); } /// Being full is not a transient condition, so retrying an upload that did /// not fit must not be offered as a fix. #[test] fn running_out_of_space_is_not_retryable() { assert!( !CoreError::from_api(&api(C::InsufficientQuota), "upload") .kind .retryable() ); } /// A failure that never reached the API is the user's connection, not /// Proton refusing — the front-end words those differently. #[test] fn a_transport_failure_reads_as_offline() { let io = std::io::Error::other("connection reset"); assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline); } /// Issue #38: the SDK refused a photo upload before sending it, and the /// app said "You're offline" while every other call went through. #[test] fn a_call_the_sdk_refuses_to_send_is_not_offline() { let refused = ProtonError::invalid_operation("account has no photos volume"); let e = CoreError::from_api(&refused, "upload photo failed"); assert_eq!(e.kind, ErrorKind::Invalid); assert!(e.message.ends_with("account has no photos volume")); let e: CoreError = Error::Proton(ProtonError::KeysLocked).into(); assert_ne!(e.kind, ErrorKind::Offline); } /// A pin or a search that fails on the database is not the connection, so /// it must not say "You're offline" (`docs/BUGS.md` B266). An SDK error /// wrapped in this crate's type classifies as the SDK error does. #[test] fn a_database_error_handed_to_from_api_is_not_offline() { let db = Error::Other("database is locked".into()); assert_eq!(CoreError::from_api(&db, "pin").kind, ErrorKind::Internal); let wrapped = Error::Proton(api(C::DoesNotExist)); assert_eq!( CoreError::from_api(&wrapped, "load node").kind, ErrorKind::NotFound ); } /// `Error` is the plumbing type; converting it must classify as sharply as /// the direct path, or the two disagree about the same failure. #[test] fn converting_from_the_plumbing_error_classifies_the_api_code() { let e: CoreError = Error::Proton(api(C::DoesNotExist)).into(); assert_eq!(e.kind, ErrorKind::NotFound); } }