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}