Files
proton-drive-linux/crates/pdfs-core/src/error.rs
T
nvrl bdbfc0bfd4 fix(core): read database errors handed to from_api as internal, not offline
from_api took every error that was not a ProtonError for a transport
failure. Pin, unpin, the searches and node loads hand it pdfs_core
errors from the cache and the database, so a failure there said
"You're offline" (B266, the rest of B205). The source chain now
decides, and only an error with neither type reads as offline.
2026-10-31 10:53:25 +01:00

461 lines
17 KiB
Rust

//! 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<HumanVerification>),
#[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<T> = std::result::Result<T, Error>;
/// 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<String>) -> 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<String>) -> Self {
Self::new(ErrorKind::NotFound, what)
}
pub fn denied(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Denied, what)
}
pub fn conflict(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Conflict, what)
}
pub fn invalid(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Invalid, what)
}
pub fn remote(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Remote, what)
}
pub fn internal(what: impl Into<String>) -> 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<rusqlite::Error> for CoreError {
fn from(e: rusqlite::Error) -> Self {
Self::internal(format!("database: {e}"))
}
}
impl From<std::io::Error> for CoreError {
fn from(e: std::io::Error) -> Self {
Self::internal(format!("io: {e}"))
}
}
impl From<serde_json::Error> for CoreError {
fn from(e: serde_json::Error) -> Self {
Self::internal(format!("serialization: {e}"))
}
}
impl From<Error> 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::<proton_sdk::ProtonError>() {
return proton_kind(proton);
}
if let Some(core) = err.downcast_ref::<Error>() {
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<T> = std::result::Result<T, CoreError>;
#[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);
}
}