Skip to main content

mtp_rs/mtp/
error.rs

1//! Backend-neutral error type for the high-level [`crate::mtp`] API.
2//!
3//! [`enum@Error`] is deliberately free of any single backend's vocabulary. The PTP-over-USB backend
4//! maps device response codes and USB faults into it; the Windows WPD backend maps `HRESULT`s into
5//! the same variants. Consumers switch on these neutral cases instead of on a backend's protocol
6//! codes. Low-level / camera users who need the raw PTP response codes use the [`crate::ptp`] layer,
7//! which keeps its detailed error type.
8
9use crate::mtp::ObjectHandle;
10use crate::ptp::ResponseCode;
11use thiserror::Error;
12
13/// The error type for high-level [`crate::mtp`] operations.
14#[derive(Debug, Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// The object or storage was not found (or never existed).
18    #[error("not found")]
19    NotFound,
20
21    /// A previously-valid handle is no longer valid because the device re-keyed it.
22    ///
23    /// Notably Android's MediaProvider re-keys object IDs across a media rescan, so a cached handle
24    /// can be silently invalidated. The fix is to re-list the parent, re-resolve, and retry once —
25    /// not to treat it as a hard not-found. See `AGENTS.md`.
26    #[error("stale object handle (device re-keyed it; re-list and retry)")]
27    StaleHandle,
28
29    /// The operation was refused: read-only storage, write-protected object, or denied access.
30    #[error("access denied")]
31    AccessDenied,
32
33    /// Another process holds the device exclusively (e.g. `ptpcamerad` on macOS, or a busy claim).
34    ///
35    /// Use this to guide users to close the conflicting app.
36    #[error("device is held exclusively by another process")]
37    ExclusiveAccess,
38
39    /// The OS denied permission to open the device.
40    ///
41    /// Distinct from [`Error::ExclusiveAccess`]: nothing else holds the device — this user/process
42    /// lacks permission to access it (most often missing Linux `udev` rules). Guide the user to fix
43    /// device permissions rather than to close another app.
44    #[error("permission denied accessing the device")]
45    PermissionDenied,
46
47    /// The device does not support this operation.
48    #[error("operation not supported by this device")]
49    Unsupported,
50
51    /// The device is temporarily busy; retrying may succeed.
52    #[error("device busy")]
53    Busy,
54
55    /// The target storage is full.
56    #[error("storage full")]
57    StorageFull,
58
59    /// The operation was cancelled (via a `CancelToken` or a stream cancel/drop).
60    #[error("operation cancelled")]
61    Cancelled,
62
63    /// The device was disconnected or stopped responding.
64    #[error("device disconnected")]
65    Disconnected,
66
67    /// The operation timed out.
68    #[error("operation timed out")]
69    Timeout,
70
71    /// No matching device was found.
72    #[error("no device found")]
73    NoDevice,
74
75    /// Data received from the device couldn't be interpreted.
76    #[error("invalid data: {message}")]
77    InvalidData {
78        /// What was invalid.
79        message: String,
80    },
81
82    /// An I/O error not covered by a more specific variant.
83    #[error("I/O error: {message}")]
84    Io {
85        /// The underlying message.
86        message: String,
87    },
88
89    /// A backend error without a more specific neutral mapping.
90    ///
91    /// `detail` carries backend-specific text (a PTP response code, an `HRESULT`) for diagnostics
92    /// only — don't pattern-match on its contents.
93    #[error("device error: {detail}")]
94    Other {
95        /// Backend-specific diagnostic text.
96        detail: String,
97    },
98}
99
100impl Error {
101    /// Create an [`Error::InvalidData`] with a message.
102    #[must_use]
103    pub fn invalid_data(message: impl Into<String>) -> Self {
104        Error::InvalidData {
105            message: message.into(),
106        }
107    }
108
109    /// Whether retrying the operation might succeed (transient failures).
110    #[must_use]
111    pub fn is_retryable(&self) -> bool {
112        matches!(self, Error::Busy | Error::Timeout)
113    }
114
115    /// Whether another process holds the device exclusively.
116    ///
117    /// Applications can use this to guide users to close the conflicting app (for example, query
118    /// IORegistry for `UsbExclusiveOwner` on macOS).
119    #[must_use]
120    pub fn is_exclusive_access(&self) -> bool {
121        matches!(self, Error::ExclusiveAccess)
122    }
123
124    /// Whether the OS denied permission to access the device (e.g. missing Linux `udev` rules).
125    ///
126    /// Distinct from [`is_exclusive_access`](Self::is_exclusive_access): the remedy is to fix
127    /// device permissions, not to close a conflicting app.
128    #[must_use]
129    pub fn is_permission_denied(&self) -> bool {
130        matches!(self, Error::PermissionDenied)
131    }
132
133    /// Whether this is the Android "re-key" case where re-listing the parent and retrying once is
134    /// the correct recovery (rather than treating it as not-found).
135    #[must_use]
136    pub fn is_stale_handle(&self) -> bool {
137        matches!(self, Error::StaleHandle)
138    }
139}
140
141impl From<crate::error::PtpError> for Error {
142    fn from(e: crate::error::PtpError) -> Self {
143        use crate::error::PtpError as Low;
144        use nusb::ErrorKind as Usb;
145        match e {
146            Low::Protocol { code, .. } => map_response_code(code),
147            // Classify USB faults by nusb's typed ErrorKind, not by message text. `Busy` covers both
148            // macOS `kIOReturnExclusiveAccess` and Linux `EBUSY` (the device is held by another app
149            // or driver); `EACCES` (missing udev permission) is the distinct `PermissionDenied`.
150            Low::Usb(usb) => match usb.kind() {
151                Usb::Busy => Error::ExclusiveAccess,
152                Usb::PermissionDenied => Error::PermissionDenied,
153                Usb::Disconnected | Usb::NotFound => Error::Disconnected,
154                Usb::Unsupported => Error::Unsupported,
155                _ => Error::Io {
156                    message: usb.to_string(),
157                },
158            },
159            Low::Io(io) => match io.kind() {
160                std::io::ErrorKind::PermissionDenied => Error::PermissionDenied,
161                _ => Error::Io {
162                    message: io.to_string(),
163                },
164            },
165            Low::InvalidData { message } => Error::InvalidData { message },
166            Low::Timeout => Error::Timeout,
167            Low::Disconnected => Error::Disconnected,
168            Low::SessionNotOpen => Error::Disconnected,
169            Low::NoDevice => Error::NoDevice,
170            Low::Cancelled => Error::Cancelled,
171        }
172    }
173}
174
175/// Map a PTP response code to a neutral [`enum@Error`].
176fn map_response_code(code: ResponseCode) -> Error {
177    match code {
178        // A previously-valid handle/parent going invalid is the Android re-key case (recoverable),
179        // not a hard not-found. Callers re-list the parent and retry once.
180        ResponseCode::InvalidObjectHandle | ResponseCode::InvalidParentObject => Error::StaleHandle,
181        ResponseCode::InvalidStorageId => Error::NotFound,
182        ResponseCode::StoreReadOnly
183        | ResponseCode::ObjectWriteProtected
184        | ResponseCode::AccessDenied => Error::AccessDenied,
185        ResponseCode::StoreFull | ResponseCode::ObjectTooLarge => Error::StorageFull,
186        ResponseCode::DeviceBusy => Error::Busy,
187        ResponseCode::OperationNotSupported | ResponseCode::ParameterNotSupported => {
188            Error::Unsupported
189        }
190        ResponseCode::TransactionCancelled => Error::Cancelled,
191        ResponseCode::SessionNotOpen => Error::Disconnected,
192        other => Error::Other {
193            detail: format!("{other:?}"),
194        },
195    }
196}
197
198/// Error from a high-level upload, carrying the handle of the object the device created during the
199/// first phase before the data phase failed.
200///
201/// Uploads are two-phase: the object is created (returning a handle), then the bytes are streamed.
202/// If the data phase fails or is cancelled, the device may keep a partial (empty or truncated)
203/// object. This surfaces that handle so the caller owns the cleanup-or-resume decision; the library
204/// never auto-deletes it. (Backends differ: the WPD backend commits atomically, so `partial` is
205/// `None` there.)
206///
207/// [`From<UploadError> for Error`] keeps `?` ergonomic; callers drop [`partial`](Self::partial)
208/// unless they match on `UploadError` explicitly.
209#[derive(Debug, Error)]
210#[error("{source}")]
211pub struct UploadError {
212    /// The underlying failure.
213    #[source]
214    pub source: Error,
215    /// The handle of the partially-written object the device may still hold, if any.
216    pub partial: Option<ObjectHandle>,
217}
218
219impl From<UploadError> for Error {
220    fn from(e: UploadError) -> Self {
221        e.source
222    }
223}
224
225impl From<crate::error::PtpUploadError> for UploadError {
226    fn from(e: crate::error::PtpUploadError) -> Self {
227        UploadError {
228            source: e.source.into(),
229            partial: e.partial.map(Into::into),
230        }
231    }
232}