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    /// A transfer cancel wedged the device, so the library reset it in software
68    /// to recover. The current session is gone; reopen the device and continue.
69    ///
70    /// Distinct from [`Disconnected`](Self::Disconnected): the device is still
71    /// present and reopenable with no physical replug. Cancelling or abandoning
72    /// an in-flight read triggers it on Android devices (issue #18), at any
73    /// transfer size; prefer `download_windowed`, whose wedge is the recoverable
74    /// one. Recover with quiet, idle-spaced reopens; see
75    /// [`MtpDevice::reset_by_serial`](crate::mtp::MtpDevice::reset_by_serial).
76    ///
77    /// **Don't treat this as the only wedge signature.** A Samsung reports it; a
78    /// Pixel wedges the same way but the next operation simply hangs with no
79    /// error at all (verified on a Pixel 9 Pro XL, macOS/nusb, 2026-07-20), so a
80    /// consumer needs a timeout around operations too, not just this match. See
81    /// `docs/notes/android-wedges-and-the-reset-kill-switch.md`.
82    #[error("device was reset to recover from a wedged cancel; reopen to continue")]
83    DeviceReset,
84
85    /// The operation timed out.
86    #[error("operation timed out")]
87    Timeout,
88
89    /// No matching device was found.
90    #[error("no device found")]
91    NoDevice,
92
93    /// Data received from the device couldn't be interpreted.
94    #[error("invalid data: {message}")]
95    InvalidData {
96        /// What was invalid.
97        message: String,
98    },
99
100    /// An I/O error not covered by a more specific variant.
101    #[error("I/O error: {message}")]
102    Io {
103        /// The underlying message.
104        message: String,
105    },
106
107    /// A backend error without a more specific neutral mapping.
108    ///
109    /// `detail` carries backend-specific text (a PTP response code, an `HRESULT`) for diagnostics
110    /// only — don't pattern-match on its contents.
111    #[error("device error: {detail}")]
112    Other {
113        /// Backend-specific diagnostic text.
114        detail: String,
115    },
116}
117
118impl Error {
119    /// Create an [`Error::InvalidData`] with a message.
120    #[must_use]
121    pub fn invalid_data(message: impl Into<String>) -> Self {
122        Error::InvalidData {
123            message: message.into(),
124        }
125    }
126
127    /// Whether retrying the operation might succeed (transient failures).
128    #[must_use]
129    pub fn is_retryable(&self) -> bool {
130        matches!(self, Error::Busy | Error::Timeout)
131    }
132
133    /// Whether the device is gone and this handle is dead.
134    ///
135    /// The check a long-lived consumer makes most: a daemon tears down the device's mount, a file
136    /// manager drops it from the sidebar. Pairs naturally with
137    /// [`HotplugEvent::Left`](crate::mtp::HotplugEvent::Left) as the other way to learn the same
138    /// thing.
139    ///
140    /// Deliberately **false** for [`Error::DeviceReset`], which is easy to lump in and shouldn't be:
141    /// there the device is still plugged in and reopenable with no replug, and only the session
142    /// died. Treating it as a disconnect throws away a device that's sitting right there. Reopen
143    /// after a quiet pause instead (see [`Error::DeviceReset`]).
144    #[must_use]
145    pub fn is_disconnected(&self) -> bool {
146        matches!(self, Error::Disconnected)
147    }
148
149    /// Whether another process holds the device exclusively.
150    ///
151    /// Applications can use this to guide users to close the conflicting app (for example, query
152    /// IORegistry for `UsbExclusiveOwner` on macOS).
153    #[must_use]
154    pub fn is_exclusive_access(&self) -> bool {
155        matches!(self, Error::ExclusiveAccess)
156    }
157
158    /// Whether the OS denied permission to access the device (e.g. missing Linux `udev` rules).
159    ///
160    /// Distinct from [`is_exclusive_access`](Self::is_exclusive_access): the remedy is to fix
161    /// device permissions, not to close a conflicting app.
162    #[must_use]
163    pub fn is_permission_denied(&self) -> bool {
164        matches!(self, Error::PermissionDenied)
165    }
166
167    /// Whether this is the Android "re-key" case where re-listing the parent and retrying once is
168    /// the correct recovery (rather than treating it as not-found).
169    #[must_use]
170    pub fn is_stale_handle(&self) -> bool {
171        matches!(self, Error::StaleHandle)
172    }
173}
174
175impl From<crate::error::PtpError> for Error {
176    fn from(e: crate::error::PtpError) -> Self {
177        use crate::error::PtpError as Low;
178        use nusb::ErrorKind as Usb;
179        match e {
180            Low::Protocol { code, .. } => map_response_code(code),
181            // Classify USB faults by nusb's typed ErrorKind, not by message text. `Busy` covers both
182            // macOS `kIOReturnExclusiveAccess` and Linux `EBUSY` (the device is held by another app
183            // or driver); `EACCES` (missing udev permission) is the distinct `PermissionDenied`.
184            Low::Usb(usb) => match usb.kind() {
185                Usb::Busy => Error::ExclusiveAccess,
186                Usb::PermissionDenied => Error::PermissionDenied,
187                Usb::Disconnected | Usb::NotFound => Error::Disconnected,
188                Usb::Unsupported => Error::Unsupported,
189                _ => Error::Io {
190                    message: usb.to_string(),
191                },
192            },
193            Low::Io(io) => match io.kind() {
194                std::io::ErrorKind::PermissionDenied => Error::PermissionDenied,
195                _ => Error::Io {
196                    message: io.to_string(),
197                },
198            },
199            Low::InvalidData { message } => Error::InvalidData { message },
200            Low::Timeout => Error::Timeout,
201            Low::Disconnected => Error::Disconnected,
202            Low::SessionNotOpen => Error::Disconnected,
203            Low::NoDevice => Error::NoDevice,
204            Low::Cancelled => Error::Cancelled,
205            Low::DeviceReset => Error::DeviceReset,
206        }
207    }
208}
209
210/// Map a PTP response code to a neutral [`enum@Error`].
211fn map_response_code(code: ResponseCode) -> Error {
212    match code {
213        // A previously-valid handle/parent going invalid is the Android re-key case (recoverable),
214        // not a hard not-found. Callers re-list the parent and retry once.
215        ResponseCode::InvalidObjectHandle | ResponseCode::InvalidParentObject => Error::StaleHandle,
216        ResponseCode::InvalidStorageId => Error::NotFound,
217        ResponseCode::StoreReadOnly
218        | ResponseCode::ObjectWriteProtected
219        | ResponseCode::AccessDenied => Error::AccessDenied,
220        ResponseCode::StoreFull | ResponseCode::ObjectTooLarge => Error::StorageFull,
221        ResponseCode::DeviceBusy => Error::Busy,
222        ResponseCode::OperationNotSupported | ResponseCode::ParameterNotSupported => {
223            Error::Unsupported
224        }
225        ResponseCode::TransactionCancelled => Error::Cancelled,
226        ResponseCode::SessionNotOpen => Error::Disconnected,
227        other => Error::Other {
228            detail: format!("{other:?}"),
229        },
230    }
231}
232
233/// Error from a high-level upload, carrying the handle of the object the device created during the
234/// first phase before the data phase failed.
235///
236/// Uploads are two-phase: the object is created (returning a handle), then the bytes are streamed.
237/// If the data phase fails or is cancelled, the device may keep a partial (empty or truncated)
238/// object. This surfaces that handle so the caller owns the cleanup-or-resume decision; the library
239/// never auto-deletes it. (Backends differ: the WPD backend commits atomically, so `partial` is
240/// `None` there.)
241///
242/// [`From<UploadError> for Error`] keeps `?` ergonomic; callers drop [`partial`](Self::partial)
243/// unless they match on `UploadError` explicitly.
244#[derive(Debug, Error)]
245#[error("{source}")]
246pub struct UploadError {
247    /// The underlying failure.
248    #[source]
249    pub source: Error,
250    /// The handle of the partially-written object the device may still hold, if any.
251    pub partial: Option<ObjectHandle>,
252}
253
254impl From<UploadError> for Error {
255    fn from(e: UploadError) -> Self {
256        e.source
257    }
258}
259
260impl From<crate::error::PtpUploadError> for UploadError {
261    fn from(e: crate::error::PtpUploadError) -> Self {
262        UploadError {
263            source: e.source.into(),
264            partial: e.partial.map(Into::into),
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn is_disconnected_covers_only_a_device_that_is_actually_gone() {
275        assert!(Error::Disconnected.is_disconnected());
276
277        // The distinction that makes the predicate worth having: after a wedged-cancel
278        // recovery the device is still plugged in and reopenable, so a consumer must NOT
279        // tear down its mount or drop the device from its list. Only the session died.
280        assert!(!Error::DeviceReset.is_disconnected());
281
282        assert!(!Error::Busy.is_disconnected());
283        assert!(!Error::Timeout.is_disconnected());
284    }
285}