1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
//! Error types for liburlx.
//!
//! All errors are represented by the [`Error`] enum, which maps to `CURLcode`
//! values at the FFI boundary.
use std::time::Duration;
/// The main error type for liburlx operations.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// A URL could not be parsed.
#[error("URL parse error: {0}")]
UrlParse(String),
/// A connection could not be established.
#[error("connection failed: {0}")]
Connect(#[source] std::io::Error),
/// A TLS handshake failed.
#[error("TLS handshake failed: {0}")]
Tls(#[source] Box<dyn std::error::Error + Send + Sync>),
/// An HTTP protocol error occurred.
#[error("HTTP protocol error: {0}")]
Http(String),
/// The operation timed out.
#[error("timeout after {0:?}")]
Timeout(Duration),
/// An I/O error (file operations, etc.).
#[error("I/O error: {0}")]
Io(#[source] std::io::Error),
/// A transfer error with a numeric code (maps to `CURLcode`).
#[error("transfer error (code {code}): {message}")]
Transfer {
/// The error code.
code: u32,
/// A human-readable error message.
message: String,
},
/// Transfer speed dropped below the minimum threshold for too long.
/// Maps to `CURLE_OPERATION_TIMEDOUT` (28) at the FFI boundary.
#[error("transfer speed {speed} B/s below limit {limit} B/s for {duration:?}")]
SpeedLimit {
/// The measured speed in bytes/sec.
speed: u64,
/// The configured minimum speed in bytes/sec.
limit: u64,
/// How long the speed has been below the limit.
duration: Duration,
},
/// An SSH protocol error occurred.
#[error("SSH error: {0}")]
Ssh(String),
/// SSH host key verification failed (maps to `CURLE_PEER_FAILED_VERIFICATION` = 60).
#[error("SSH host key verification failed: {0}")]
SshHostKeyMismatch(String),
/// SFTP/SCP quote command failed (maps to `CURLE_QUOTE_ERROR` = 21).
#[error("SFTP quote error: {0}")]
SshQuoteError(String),
/// SCP/SFTP upload failed (maps to `CURLE_UPLOAD_FAILED` = 25).
#[error("SSH upload failed: {0}")]
SshUploadFailed(String),
/// SFTP range request not satisfiable (maps to `CURLE_RANGE_ERROR` = 33).
#[error("SFTP range error: {0}")]
SshRangeError(String),
/// SFTP/SCP post-quote command failed, but download data is available.
/// The response is boxed to keep the error type small.
#[error("SFTP post-quote error: {message}")]
SshQuoteErrorWithData {
/// Error description.
message: String,
/// The response data from the successful download.
response: Box<crate::protocol::http::response::Response>,
},
/// An authentication error occurred.
#[error("authentication error: {0}")]
Auth(String),
/// The protocol scheme is not supported.
#[error("unsupported protocol: {0}")]
UnsupportedProtocol(String),
/// DNS name resolution failed.
#[error("could not resolve host: {0}")]
DnsResolve(String),
/// A file:// protocol read or write failed.
#[error("file read/write error: {0}")]
FileError(String),
/// Body read failed with partial data available.
/// The `partial_body` contains whatever was successfully decoded before
/// the error (e.g., valid chunks before an invalid chunk size).
#[error("partial body error: {message}")]
PartialBody {
/// Error description.
message: String,
/// Body data decoded before the error.
partial_body: Vec<u8>,
},
/// An SMTP authentication error (maps to `CURLE_LOGIN_DENIED` = 67).
#[error("SMTP auth error: {0}")]
SmtpAuth(String),
/// An SMTP send error (maps to `CURLE_SEND_ERROR` = 55).
#[error("SMTP send error: {0}")]
SmtpSend(String),
/// RTSP `CSeq` mismatch between client and server (maps to `CURLE_RTSP_CSEQ_ERROR` = 85).
#[error("RTSP CSeq mismatch: {0}")]
RtspCseqError(String),
/// RTSP `Session` ID mismatch (maps to `CURLE_RTSP_SESSION_ERROR` = 86).
#[error("RTSP Session mismatch: {0}")]
RtspSessionError(String),
/// A generic protocol error with a curl error code.
#[error("protocol error (code {0})")]
Protocol(u32),
/// A URL glob pattern error with position info (curl-compatible format).
/// Formats as:
/// ```text
/// bad range in URL position 47:
/// http://example.com/[2-1]
/// ^
/// ```
#[error("{}", format_url_glob_error(message, url, *position))]
UrlGlob {
/// Error message (e.g., "bad range in URL position 47:").
message: String,
/// The original URL pattern.
url: String,
/// Character position (0-indexed) for the caret indicator.
position: usize,
},
}
/// Format a URL glob error with position caret.
///
/// For "bad range" errors, includes a `^` caret indicator pointing at the error position.
/// For "too many" errors (curl compat: test 761), only shows the message and truncated URL.
fn format_url_glob_error(message: &str, url: &str, position: usize) -> String {
if url.is_empty() {
return message.to_string();
}
// "too many" errors don't show a caret indicator (curl compat: test 761)
if message.starts_with("too many") {
return format!("{message}\n{url}");
}
format!("{message}\n{url}\n{:>width$}", "^", width = position + 1)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn error_display_url_parse() {
let err = Error::UrlParse("missing scheme".to_string());
assert_eq!(err.to_string(), "URL parse error: missing scheme");
}
#[test]
fn error_display_timeout() {
let err = Error::Timeout(Duration::from_secs(30));
assert_eq!(err.to_string(), "timeout after 30s");
}
#[test]
fn error_display_transfer() {
let err = Error::Transfer { code: 7, message: "connection refused".to_string() };
assert_eq!(err.to_string(), "transfer error (code 7): connection refused");
}
#[test]
fn error_display_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = Error::Io(io_err);
assert!(err.to_string().contains("file not found"));
}
#[test]
fn error_display_speed_limit() {
let err = Error::SpeedLimit { speed: 50, limit: 100, duration: Duration::from_secs(10) };
assert_eq!(err.to_string(), "transfer speed 50 B/s below limit 100 B/s for 10s");
}
#[test]
fn error_display_ssh() {
let err = Error::Ssh("authentication failed".to_string());
assert_eq!(err.to_string(), "SSH error: authentication failed");
}
#[test]
fn error_display_auth() {
let err = Error::Auth("SCRAM nonce mismatch".to_string());
assert_eq!(err.to_string(), "authentication error: SCRAM nonce mismatch");
}
#[test]
fn error_display_unsupported_protocol() {
let err = Error::UnsupportedProtocol("gopher".to_string());
assert_eq!(err.to_string(), "unsupported protocol: gopher");
}
#[test]
fn error_display_dns_resolve() {
let err = Error::DnsResolve("nonexistent.example.com".to_string());
assert_eq!(err.to_string(), "could not resolve host: nonexistent.example.com");
}
#[test]
fn error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Error>();
}
}