rsurl 0.0.2

A pure-Rust implementation of curl. Library, C FFI, and CLI for HTTP/HTTPS/FTP/FTPS.
Documentation
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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! C ABI for rsurl.
//!
//! A deliberately minimal, libcurl-shaped "easy" API. Function names use a
//! `rsurl_` prefix (not `curl_`) so this can be linked alongside libcurl
//! without symbol clashes, while still being familiar to anyone who has
//! used libcurl before.
//!
//! Lifecycle:
//!
//! ```c
//! RSURL *h = rsurl_easy_init();
//! rsurl_easy_setopt(h, RSURLOPT_URL, "http://example.com");
//! rsurl_easy_perform(h);
//! const uint8_t *body; size_t len;
//! rsurl_easy_response_body(h, &body, &len);
//! rsurl_easy_cleanup(h);
//! ```
//!
//! All pointer parameters except `handle` may be NULL where stated. Returned
//! pointers from `rsurl_easy_response_*` borrow from the handle and become
//! invalid on the next `rsurl_easy_perform` or `rsurl_easy_cleanup`.

#![allow(non_camel_case_types)]

use std::ffi::{c_char, c_int, c_long, CStr};
use std::panic::{self, AssertUnwindSafe};
use std::ptr;

use crate::http::{Request, Response};

/// Unwind barrier for the C ABI: a Rust panic crossing an `extern "C"`
/// boundary is undefined behavior, so every exported function runs its body
/// through `catch_unwind` and converts a caught panic into `default`.
///
/// `AssertUnwindSafe` is sound here because the closures only touch a single
/// handle behind a raw pointer (no shared `&mut` state observed after a
/// panic) and any panic aborts the operation before returning a value to C;
/// the handle is left in a valid—if possibly partially-updated—state, which
/// is the same guarantee callers already get from an error return.
fn ffi_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
    panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or(default)
}

/// Opaque handle. Never dereferenced from C.
pub enum RSURL {}

/// Option codes accepted by `rsurl_easy_setopt`.
#[repr(C)]
pub enum RsurlOpt {
    /// const char* — the target URL.
    Url = 1,
    /// const char* — override HTTP method (default GET, or POST if body set).
    CustomRequest = 2,
    /// const char* — single header line ("Name: value"). Repeat to add multiple.
    Header = 3,
    /// const char* — request body (UTF-8 string, NUL-terminated).
    PostFieldsString = 4,
    /// long — connect timeout in seconds. 0 disables.
    ConnectTimeout = 5,
    /// long — read timeout in seconds. 0 disables.
    Timeout = 6,
    /// const char* — User-Agent value.
    UserAgent = 7,
}

/// Status codes returned by the API.
#[repr(C)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum RsurlCode {
    Ok = 0,
    InvalidHandle = 1,
    UnknownOption = 2,
    InvalidArg = 3,
    NoResponse = 4,
    Network = 5,
    BadResponse = 6,
    Unsupported = 7,
}

struct Handle {
    url: Option<String>,
    method: Option<String>,
    user_agent: Option<String>,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    connect_timeout_secs: Option<u64>,
    timeout_secs: Option<u64>,
    last_response: Option<Response>,
    /// Stable storage so C callers can read header values as NUL-terminated
    /// strings without us having to allocate per-call.
    header_buf: Vec<Vec<u8>>,
}

impl Handle {
    fn new() -> Self {
        Handle {
            url: None,
            method: None,
            user_agent: None,
            headers: Vec::new(),
            body: None,
            connect_timeout_secs: None,
            timeout_secs: None,
            last_response: None,
            header_buf: Vec::new(),
        }
    }
}

fn handle_mut<'a>(h: *mut RSURL) -> Option<&'a mut Handle> {
    if h.is_null() {
        return None;
    }
    // SAFETY: handles are always created from Box::into_raw of a Handle.
    Some(unsafe { &mut *(h as *mut Handle) })
}

fn handle_ref<'a>(h: *const RSURL) -> Option<&'a Handle> {
    if h.is_null() {
        return None;
    }
    // SAFETY: handles are always created from Box::into_raw of a Handle.
    Some(unsafe { &*(h as *const Handle) })
}

/// Allocate a new easy handle. Returns NULL on allocation failure (practically
/// never on this platform). Free with `rsurl_easy_cleanup`.
#[no_mangle]
pub extern "C" fn rsurl_easy_init() -> *mut RSURL {
    ffi_guard(ptr::null_mut(), || {
        let boxed = Box::new(Handle::new());
        Box::into_raw(boxed) as *mut RSURL
    })
}

/// Free an easy handle. NULL is a no-op.
#[no_mangle]
pub extern "C" fn rsurl_easy_cleanup(handle: *mut RSURL) {
    ffi_guard((), || {
        if handle.is_null() {
            return;
        }
        // SAFETY: handle came from rsurl_easy_init's Box::into_raw.
        unsafe {
            drop(Box::from_raw(handle as *mut Handle));
        }
    })
}

/// Reset all options on a handle but keep it allocated. Clears any previous
/// response data.
#[no_mangle]
pub extern "C" fn rsurl_easy_reset(handle: *mut RSURL) -> RsurlCode {
    ffi_guard(RsurlCode::Network, || {
        let Some(h) = handle_mut(handle) else {
            return RsurlCode::InvalidHandle;
        };
        *h = Handle::new();
        RsurlCode::Ok
    })
}

/// Set an option taking a NUL-terminated `const char*`.
///
/// Pass NULL to clear/unset that option. The string is copied into the handle.
///
/// # Safety
///
/// `handle` must be a pointer returned by [`rsurl_easy_init`] and not yet
/// freed by [`rsurl_easy_cleanup`]. `value`, if non-null, must point to a
/// valid NUL-terminated C string for the duration of this call.
#[no_mangle]
pub unsafe extern "C" fn rsurl_easy_setopt_str(
    handle: *mut RSURL,
    option: c_int,
    value: *const c_char,
) -> RsurlCode {
    ffi_guard(RsurlCode::Network, || {
        let Some(h) = handle_mut(handle) else {
            return RsurlCode::InvalidHandle;
        };
        let s = if value.is_null() {
            None
        } else {
            // SAFETY: caller asserts value is a valid NUL-terminated C string.
            match unsafe { CStr::from_ptr(value) }.to_str() {
                Ok(s) => Some(s.to_string()),
                Err(_) => return RsurlCode::InvalidArg,
            }
        };
        let Some(opt) = opt_from_int(option) else {
            return RsurlCode::UnknownOption;
        };
        match opt {
            RsurlOpt::Url => h.url = s,
            RsurlOpt::CustomRequest => h.method = s,
            RsurlOpt::UserAgent => h.user_agent = s,
            RsurlOpt::Header => match s {
                Some(line) => {
                    let Some((k, v)) = line.split_once(':') else {
                        return RsurlCode::InvalidArg;
                    };
                    h.headers.push((k.trim().to_string(), v.trim().to_string()));
                }
                None => h.headers.clear(),
            },
            RsurlOpt::PostFieldsString => h.body = s.map(|s| s.into_bytes()),
            RsurlOpt::ConnectTimeout | RsurlOpt::Timeout => return RsurlCode::InvalidArg,
        }
        RsurlCode::Ok
    })
}

/// Set an option taking a `long` (e.g. timeouts).
#[no_mangle]
pub extern "C" fn rsurl_easy_setopt_long(
    handle: *mut RSURL,
    option: c_int,
    value: c_long,
) -> RsurlCode {
    ffi_guard(RsurlCode::Network, || {
        let Some(h) = handle_mut(handle) else {
            return RsurlCode::InvalidHandle;
        };
        let Some(opt) = opt_from_int(option) else {
            return RsurlCode::UnknownOption;
        };
        let secs = if value <= 0 { None } else { Some(value as u64) };
        match opt {
            RsurlOpt::ConnectTimeout => h.connect_timeout_secs = secs,
            RsurlOpt::Timeout => h.timeout_secs = secs,
            _ => return RsurlCode::InvalidArg,
        }
        RsurlCode::Ok
    })
}

fn opt_from_int(v: c_int) -> Option<RsurlOpt> {
    Some(match v {
        1 => RsurlOpt::Url,
        2 => RsurlOpt::CustomRequest,
        3 => RsurlOpt::Header,
        4 => RsurlOpt::PostFieldsString,
        5 => RsurlOpt::ConnectTimeout,
        6 => RsurlOpt::Timeout,
        7 => RsurlOpt::UserAgent,
        _ => return None,
    })
}

/// Execute the request configured on the handle. Replaces any previous
/// response stored on the handle.
#[no_mangle]
pub extern "C" fn rsurl_easy_perform(handle: *mut RSURL) -> RsurlCode {
    ffi_guard(RsurlCode::Network, || {
        let Some(h) = handle_mut(handle) else {
            return RsurlCode::InvalidHandle;
        };
        let Some(url) = h.url.as_deref() else {
            return RsurlCode::InvalidArg;
        };
        let method = h.method.clone().unwrap_or_else(|| {
            if h.body.is_some() {
                "POST".to_string()
            } else {
                "GET".to_string()
            }
        });

        let mut req = match Request::new(&method, url) {
            Ok(r) => r,
            Err(crate::Error::UnsupportedScheme(_)) => return RsurlCode::Unsupported,
            Err(_) => return RsurlCode::InvalidArg,
        };
        for (k, v) in &h.headers {
            req = req.header(k, v);
        }
        if let Some(ua) = &h.user_agent {
            req = req.header("User-Agent", ua);
        }
        if let Some(body) = h.body.clone() {
            req = req.body(body);
        }

        match req.send() {
            Ok(resp) => {
                h.header_buf = resp
                    .headers
                    .iter()
                    .map(|(k, v)| {
                        let mut s = format!("{k}: {v}").into_bytes();
                        s.push(0);
                        s
                    })
                    .collect();
                h.last_response = Some(resp);
                RsurlCode::Ok
            }
            Err(crate::Error::UnsupportedScheme(_)) => RsurlCode::Unsupported,
            Err(crate::Error::Io(_)) | Err(crate::Error::UnexpectedEof) => RsurlCode::Network,
            Err(crate::Error::BadResponse(_)) | Err(crate::Error::H2NotNegotiated) => {
                RsurlCode::BadResponse
            }
            Err(crate::Error::InvalidUrl(_)) => RsurlCode::InvalidArg,
        }
    })
}

/// Borrow a pointer to the response body and its length. Pointer remains
/// valid until the next perform/reset/cleanup. `out_ptr` is set to NULL and
/// `out_len` to 0 if no response is available.
///
/// The returned buffer is **raw response bytes plus a length**; it is **not**
/// NUL-terminated and may contain embedded NUL bytes. C callers must use the
/// `*out_len` value and must **not** pass `*out_ptr` to `strlen`, `printf`,
/// or any function that treats it as a NUL-terminated C string.
///
/// # Safety
///
/// `handle` must be a pointer returned by [`rsurl_easy_init`] and not yet
/// freed by [`rsurl_easy_cleanup`]. `out_ptr` and `out_len` must be non-null
/// and point to writable storage of the appropriate type.
#[no_mangle]
pub unsafe extern "C" fn rsurl_easy_response_body(
    handle: *const RSURL,
    out_ptr: *mut *const u8,
    out_len: *mut usize,
) -> RsurlCode {
    ffi_guard(RsurlCode::Network, || {
        let Some(h) = handle_ref(handle) else {
            return RsurlCode::InvalidHandle;
        };
        if out_ptr.is_null() || out_len.is_null() {
            return RsurlCode::InvalidArg;
        }
        match &h.last_response {
            Some(resp) => unsafe {
                *out_ptr = resp.body.as_ptr();
                *out_len = resp.body.len();
            },
            None => unsafe {
                *out_ptr = ptr::null();
                *out_len = 0;
            },
        }
        RsurlCode::Ok
    })
}

/// Return the response HTTP status code, or 0 if no response is available.
#[no_mangle]
pub extern "C" fn rsurl_easy_response_status(handle: *const RSURL) -> c_long {
    ffi_guard(0, || {
        handle_ref(handle)
            .and_then(|h| h.last_response.as_ref())
            .map(|r| r.status as c_long)
            .unwrap_or(0)
    })
}

/// Borrow a pointer to a NUL-terminated `"Name: value"` header line by index.
/// Returns NULL if `index` is out of range or no response is available.
#[no_mangle]
pub extern "C" fn rsurl_easy_response_header(handle: *const RSURL, index: usize) -> *const c_char {
    ffi_guard(ptr::null(), || {
        let Some(h) = handle_ref(handle) else {
            return ptr::null();
        };
        h.header_buf
            .get(index)
            .map(|b| b.as_ptr() as *const c_char)
            .unwrap_or(ptr::null())
    })
}

/// Return the number of response headers available.
#[no_mangle]
pub extern "C" fn rsurl_easy_response_header_count(handle: *const RSURL) -> usize {
    ffi_guard(0, || {
        handle_ref(handle).map(|h| h.header_buf.len()).unwrap_or(0)
    })
}

/// Return a static, NUL-terminated human-readable string for a status code.
#[no_mangle]
pub extern "C" fn rsurl_strerror(code: c_int) -> *const c_char {
    ffi_guard(ptr::null(), || {
        let s: &'static [u8] = match code {
            0 => b"ok\0",
            1 => b"invalid handle\0",
            2 => b"unknown option\0",
            3 => b"invalid argument\0",
            4 => b"no response available\0",
            5 => b"network error\0",
            6 => b"bad response\0",
            7 => b"unsupported scheme or feature\0",
            _ => b"unknown error\0",
        };
        s.as_ptr() as *const c_char
    })
}

/// Return the rsurl version as a NUL-terminated string.
#[no_mangle]
pub extern "C" fn rsurl_version() -> *const c_char {
    ffi_guard(ptr::null(), || {
        concat!("rsurl/", env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
    })
}