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}