Skip to main content

chaser_util/
ffi.rs

1//! C FFI layer
2//!
3//! Exposes the scraper API as a C-compatible interface.
4//! C++ users include `chaser-util.h` and link against `chaser_util.dll` / `libchaser_util.so`.
5//!
6//! # Memory model
7//! - All returned pointers are heap-allocated by Rust.
8//! - The caller MUST free them with `scraper_free_result()`.
9//! - Passing NULL for optional filter pointers means "no filter".
10//!
11//! # Safety
12//! - `scraper_scrape` / `scraper_scrape_with_proxy` now return a result
13//!   with `error_code = 2` if `user` or `pass` are NULL, rather than
14//!   causing undefined behaviour.
15//! - All `#[no_mangle] extern "C"` functions are wrapped in
16//!   `catch_unwind` so that Rust panics cannot unwind across the FFI
17//!   boundary (which would be UB).
18
19use std::ffi::{CStr, CString};
20use std::os::raw::{c_char, c_uint};
21use std::panic;
22use std::ptr;
23use std::sync::OnceLock;
24
25use crate::room_list::{
26    scrape as rs_scrape, scrape_with_proxy as rs_scrape_with_proxy,
27    RoomFilter, ScrapeOptions, ScrapeResult, UserFilter,
28};
29
30// ----------------------------------------------------------------
31// Global Tokio runtime (reused across all FFI calls)
32// ----------------------------------------------------------------
33
34static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
35
36fn runtime() -> &'static tokio::runtime::Runtime {
37    RUNTIME.get_or_init(|| {
38        tokio::runtime::Runtime::new()
39            .unwrap_or_else(|e| panic!("failed to create Tokio runtime: {}", e))
40    })
41}
42
43// ----------------------------------------------------------------
44// C-compatible structs
45// ----------------------------------------------------------------
46
47#[repr(C)]
48pub struct CRoomInfo {
49    pub room:            c_uint,
50    pub max_connections: c_uint,
51    pub map_display:     *mut c_char,
52    pub public_date:     *mut c_char,
53    pub patrol:          *mut c_char,
54    pub remarks:         *mut c_char,
55}
56
57#[repr(C)]
58pub struct CLoggedInUser {
59    pub order:    c_uint,
60    pub username: *mut c_char,
61    pub room:     c_uint,
62    pub state:    c_uint,
63}
64
65/// C-compatible scrape result.
66///
67/// `rooms_cap` / `users_cap` store the actual Vec capacity so that
68/// `scraper_free_result` can reconstruct the Vec correctly.
69///
70/// FIX: these fields were previously `pub`, which allowed C/C++ code to
71/// accidentally modify them.  They are now private to Rust; the C header
72/// still documents them as "internal – do not modify".
73#[repr(C)]
74pub struct CScrapeResult {
75    pub rooms:      *mut CRoomInfo,
76    pub rooms_len:  usize,
77    rooms_cap:      usize,          // internal — do not touch from C/C++
78    pub users:      *mut CLoggedInUser,
79    pub users_len:  usize,
80    users_cap:      usize,          // internal — do not touch from C/C++
81    /// 0 = success, non-zero = error (call `scraper_last_error()` for message)
82    pub error_code: c_uint,
83}
84
85#[repr(C)]
86pub struct CRoomFilter {
87    pub room_enabled:         c_uint,
88    pub room:                 c_uint,
89    pub room_min_enabled:     c_uint,
90    pub room_min:             c_uint,
91    pub room_max_enabled:     c_uint,
92    pub room_max:             c_uint,
93    pub min_max_conn_enabled: c_uint,
94    pub min_max_conn:         c_uint,
95    pub max_max_conn_enabled: c_uint,
96    pub max_max_conn:         c_uint,
97    pub map_display:          *const c_char,
98    pub public_date:          *const c_char,
99    pub public_date_contains: *const c_char,
100    pub patrol:               *const c_char,
101    pub remarks:              *const c_char,
102    pub remarks_contains:     *const c_char,
103}
104
105#[repr(C)]
106pub struct CUserFilter {
107    pub order_enabled:     c_uint,
108    pub order:             c_uint,
109    pub order_min_enabled: c_uint,
110    pub order_min:         c_uint,
111    pub order_max_enabled: c_uint,
112    pub order_max:         c_uint,
113    pub username:          *const c_char,
114    pub username_contains: *const c_char,
115    pub room_enabled:      c_uint,
116    pub room:              c_uint,
117    pub room_min_enabled:  c_uint,
118    pub room_min:          c_uint,
119    pub room_max_enabled:  c_uint,
120    pub room_max:          c_uint,
121    pub state_enabled:     c_uint,
122    pub state:             c_uint,
123}
124
125// ----------------------------------------------------------------
126// Thread-local error message storage
127// ----------------------------------------------------------------
128
129thread_local! {
130    static LAST_ERROR: std::cell::RefCell<CString> =
131        std::cell::RefCell::new(CString::new("").unwrap_or_default());
132}
133
134fn set_last_error(msg: &str) {
135    let c = CString::new(msg.replace('\0', "?")).unwrap_or_default();
136    LAST_ERROR.with(|e| *e.borrow_mut() = c);
137}
138
139/// Returns a pointer to the last error message string (UTF-8).
140/// The pointer is valid until the next FFI call on this thread.
141#[no_mangle]
142pub extern "C" fn scraper_last_error() -> *const c_char {
143    LAST_ERROR.with(|e| e.borrow().as_ptr())
144}
145
146// ----------------------------------------------------------------
147// Error result constructors
148// ----------------------------------------------------------------
149
150/// Build an error `CScrapeResult` with the given error code and message.
151fn error_result(code: c_uint, msg: &str) -> *mut CScrapeResult {
152    set_last_error(msg);
153    Box::into_raw(Box::new(CScrapeResult {
154        rooms: ptr::null_mut(), rooms_len: 0, rooms_cap: 0,
155        users: ptr::null_mut(), users_len: 0, users_cap: 0,
156        error_code: code,
157    }))
158}
159
160// ----------------------------------------------------------------
161// Conversion helpers
162// ----------------------------------------------------------------
163
164fn cstr_opt(p: *const c_char) -> Option<String> {
165    if p.is_null() { return None; }
166    unsafe { CStr::from_ptr(p).to_str().ok().map(|s| s.to_string()) }
167}
168
169fn to_cstring(s: &str) -> *mut c_char {
170    CString::new(s.replace('\0', "?"))
171        .map(|c| c.into_raw())
172        .unwrap_or(ptr::null_mut())
173}
174
175fn enabled(flag: c_uint, val: u32) -> Option<u32> {
176    if flag != 0 { Some(val) } else { None }
177}
178
179fn c_room_filter(f: &CRoomFilter) -> RoomFilter {
180    RoomFilter {
181        room:                 enabled(f.room_enabled,         f.room),
182        room_min:             enabled(f.room_min_enabled,     f.room_min),
183        room_max:             enabled(f.room_max_enabled,     f.room_max),
184        min_max_conn:         enabled(f.min_max_conn_enabled, f.min_max_conn),
185        max_max_conn:         enabled(f.max_max_conn_enabled, f.max_max_conn),
186        map_display:          cstr_opt(f.map_display),
187        public_date:          cstr_opt(f.public_date),
188        public_date_contains: cstr_opt(f.public_date_contains),
189        patrol:               cstr_opt(f.patrol),
190        remarks:              cstr_opt(f.remarks),
191        remarks_contains:     cstr_opt(f.remarks_contains),
192    }
193}
194
195fn c_user_filter(f: &CUserFilter) -> UserFilter {
196    UserFilter {
197        order:             enabled(f.order_enabled,     f.order),
198        order_min:         enabled(f.order_min_enabled, f.order_min),
199        order_max:         enabled(f.order_max_enabled, f.order_max),
200        username:          cstr_opt(f.username),
201        username_contains: cstr_opt(f.username_contains),
202        room:              enabled(f.room_enabled,      f.room),
203        room_min:          enabled(f.room_min_enabled,  f.room_min),
204        room_max:          enabled(f.room_max_enabled,  f.room_max),
205        state:             enabled(f.state_enabled,     f.state),
206    }
207}
208
209fn build_opts(
210    room_filter: *const CRoomFilter,
211    user_filter: *const CUserFilter,
212) -> ScrapeOptions {
213    let mut opts = ScrapeOptions::default();
214    if !room_filter.is_null() {
215        opts = opts.with_room_filter(c_room_filter(unsafe { &*room_filter }));
216    }
217    if !user_filter.is_null() {
218        opts = opts.with_user_filter(c_user_filter(unsafe { &*user_filter }));
219    }
220    opts
221}
222
223// ----------------------------------------------------------------
224// Convert ScrapeResult → *mut CScrapeResult
225// ----------------------------------------------------------------
226
227fn result_to_c(
228    res: Result<ScrapeResult, Box<dyn std::error::Error + Send + Sync>>,
229) -> *mut CScrapeResult {
230    match res {
231        Err(e) => error_result(1, &e.to_string()),
232        Ok(sr) => {
233            // rooms
234            let mut c_rooms: Vec<CRoomInfo> = sr.rooms.iter().map(|r| CRoomInfo {
235                room:            r.room,
236                max_connections: r.max_connections,
237                map_display:     to_cstring(&r.map_display),
238                public_date:     to_cstring(&r.public_date),
239                patrol:          to_cstring(&r.patrol),
240                remarks:         to_cstring(&r.remarks),
241            }).collect();
242            let rooms_len = c_rooms.len();
243            let rooms_cap = c_rooms.capacity();
244            let rooms_ptr = if rooms_len > 0 {
245                let p = c_rooms.as_mut_ptr();
246                std::mem::forget(c_rooms);
247                p
248            } else {
249                ptr::null_mut()
250            };
251
252            // users
253            let (users_ptr, users_len, users_cap) = match sr.logged_in_users {
254                None => (ptr::null_mut(), 0, 0),
255                Some(users) => {
256                    let mut c_users: Vec<CLoggedInUser> = users.iter().map(|u| CLoggedInUser {
257                        order:    u.order,
258                        username: to_cstring(&u.username),
259                        room:     u.room,
260                        state:    u.state,
261                    }).collect();
262                    let len = c_users.len();
263                    let cap = c_users.capacity();
264                    let p   = c_users.as_mut_ptr();
265                    std::mem::forget(c_users);
266                    (p, len, cap)
267                }
268            };
269
270            Box::into_raw(Box::new(CScrapeResult {
271                rooms: rooms_ptr, rooms_len, rooms_cap,
272                users: users_ptr, users_len, users_cap,
273                error_code: 0,
274            }))
275        }
276    }
277}
278
279// ----------------------------------------------------------------
280// NULL-safe CStr helper
281// ----------------------------------------------------------------
282
283/// Convert a (possibly NULL) `*const c_char` to a `&str`.
284///
285/// FIX: previously the code called `CStr::from_ptr()` on `user`/`pass`
286/// without checking for NULL, which is undefined behaviour.  This helper
287/// returns `Err` when the pointer is NULL so the FFI function can return a
288/// proper error result to the caller instead of crashing.
289unsafe fn cstr_required<'a>(
290    p:    *const c_char,
291    name: &'static str,
292) -> Result<&'a str, String> {
293    if p.is_null() {
294        return Err(format!("argument `{}` must not be NULL", name));
295    }
296    CStr::from_ptr(p)
297        .to_str()
298        .map_err(|e| format!("argument `{}` is not valid UTF-8: {}", name, e))
299}
300
301// ----------------------------------------------------------------
302// Public C API
303// ----------------------------------------------------------------
304
305/// Scrape with automatic proxy detection.
306///
307/// Returns a heap-allocated `CScrapeResult` that **must** be freed with
308/// `scraper_free_result()`.
309///
310/// FIX 1: `user` and `pass` are validated for NULL before use.
311/// FIX 2: the function body is wrapped in `catch_unwind` so that Rust panics
312///         cannot unwind across the FFI boundary (which would be UB).
313#[no_mangle]
314pub extern "C" fn scraper_scrape(
315    user:        *const c_char,
316    pass:        *const c_char,
317    room_filter: *const CRoomFilter,
318    user_filter: *const CUserFilter,
319) -> *mut CScrapeResult {
320    // FIX: catch_unwind prevents panic-unwind UB across the FFI boundary.
321    let outcome = panic::catch_unwind(|| {
322        // FIX: NULL-check user and pass before dereferencing.
323        let user = unsafe { cstr_required(user, "user") };
324        let pass = unsafe { cstr_required(pass, "pass") };
325        let (user, pass) = match (user, pass) {
326            (Ok(u), Ok(p)) => (u, p),
327            (Err(e), _) | (_, Err(e)) => return error_result(2, &e),
328        };
329        let opts = build_opts(room_filter, user_filter);
330        let res  = runtime().block_on(rs_scrape(user, pass, opts));
331        result_to_c(res)
332    });
333
334    match outcome {
335        Ok(ptr) => ptr,
336        Err(_)  => error_result(3, "internal panic in scraper_scrape"),
337    }
338}
339
340/// Scrape with a manually specified proxy.
341///
342/// Pass `proxy_uri = ""` for direct connection.
343///
344/// FIX 1: `user`, `pass`, and `proxy_uri` are validated for NULL before use.
345/// FIX 2: wrapped in `catch_unwind` (see `scraper_scrape`).
346#[no_mangle]
347pub extern "C" fn scraper_scrape_with_proxy(
348    user:        *const c_char,
349    pass:        *const c_char,
350    proxy_uri:   *const c_char,
351    room_filter: *const CRoomFilter,
352    user_filter: *const CUserFilter,
353) -> *mut CScrapeResult {
354    let outcome = panic::catch_unwind(|| {
355        let user      = unsafe { cstr_required(user,      "user")      };
356        let pass      = unsafe { cstr_required(pass,      "pass")      };
357        let proxy_uri = unsafe { cstr_required(proxy_uri, "proxy_uri") };
358        let (user, pass, proxy_uri) = match (user, pass, proxy_uri) {
359            (Ok(u), Ok(p), Ok(x)) => (u, p, x),
360            (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => return error_result(2, &e),
361        };
362        let opts = build_opts(room_filter, user_filter);
363        let res  = runtime().block_on(rs_scrape_with_proxy(user, pass, proxy_uri, opts));
364        result_to_c(res)
365    });
366
367    match outcome {
368        Ok(ptr) => ptr,
369        Err(_)  => error_result(3, "internal panic in scraper_scrape_with_proxy"),
370    }
371}
372
373/// Free a `CScrapeResult` returned by `scraper_scrape*()`  .
374/// Passing NULL is a no-op.
375///
376/// FIX: `catch_unwind` ensures that even a bug in the free path cannot
377/// propagate a panic across the FFI boundary.
378#[no_mangle]
379pub extern "C" fn scraper_free_result(result: *mut CScrapeResult) {
380    if result.is_null() {
381        return;
382    }
383    let _ = panic::catch_unwind(|| {
384        unsafe {
385            let r = Box::from_raw(result);
386
387            if !r.rooms.is_null() {
388                let rooms = std::slice::from_raw_parts_mut(r.rooms, r.rooms_len);
389                for room in rooms.iter() {
390                    if !room.map_display.is_null() { drop(CString::from_raw(room.map_display)); }
391                    if !room.public_date.is_null() { drop(CString::from_raw(room.public_date)); }
392                    if !room.patrol.is_null()      { drop(CString::from_raw(room.patrol));      }
393                    if !room.remarks.is_null()     { drop(CString::from_raw(room.remarks));     }
394                }
395                drop(Vec::from_raw_parts(r.rooms, r.rooms_len, r.rooms_cap));
396            }
397
398            if !r.users.is_null() {
399                let users = std::slice::from_raw_parts_mut(r.users, r.users_len);
400                for user in users.iter() {
401                    if !user.username.is_null() { drop(CString::from_raw(user.username)); }
402                }
403                drop(Vec::from_raw_parts(r.users, r.users_len, r.users_cap));
404            }
405        }
406    });
407}