Skip to main content

webui_ffi/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4// FFI crate requires unsafe for C-compatible ABI boundary.
5#![allow(unsafe_code)]
6
7//! WebUI FFI (Foreign Function Interface) for interoperability with other languages.
8//!
9//! This crate provides C-compatible APIs for the WebUI handler to be used from languages
10//! like Go, C#, Python, etc.
11//!
12//! Load a compiled protocol once with [`webui_protocol_create`], then reuse the
13//! handle with [`webui_handler_render`] and the other protocol operations.
14//!
15//! ## Error Handling
16//!
17//! All functions that can fail return `NULL` on error. Call [`webui_last_error`] to
18//! retrieve a human-readable error message. The error string is valid until the next
19//! FFI call on the same thread (follows the POSIX `dlerror()` pattern).
20
21use serde_json::Value;
22use std::cell::RefCell;
23use std::ffi::{CStr, CString};
24use std::os::raw::{c_char, c_void};
25use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
26use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
27use webui_handler::plugin::webui::WebUIHydrationPlugin;
28use webui_handler::{Protocol, RenderOptions, ResponseWriter, WebUIHandler};
29
30/// Opaque C handle for a loaded WebUI protocol.
31#[allow(non_camel_case_types)]
32pub type webui_protocol_t = c_void;
33
34// ---------------------------------------------------------------------------
35// Thread-local error storage (POSIX dlerror() pattern)
36// ---------------------------------------------------------------------------
37
38thread_local! {
39    /// Stores the last error message for the current thread.
40    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
41}
42
43/// Record an error message so that `webui_last_error()` can return it.
44fn set_last_error(msg: impl Into<String>) {
45    let mut bytes = msg.into().into_bytes();
46    if let Some(nul_pos) = bytes.iter().position(|byte| *byte == 0) {
47        bytes.truncate(nul_pos);
48    }
49
50    // SAFETY: Any interior NUL byte was removed by truncating at its first position.
51    let c_string = unsafe { CString::from_vec_unchecked(bytes) };
52    LAST_ERROR.with(|cell| {
53        cell.replace(Some(c_string));
54    });
55}
56
57/// Clear any previously stored error.
58fn clear_last_error() {
59    LAST_ERROR.with(|cell| {
60        cell.replace(None);
61    });
62}
63
64// ---------------------------------------------------------------------------
65// Internal helpers
66// ---------------------------------------------------------------------------
67
68/// Opaque context wrapping a `WebUIHandler`.
69struct HandlerContext {
70    handler: WebUIHandler,
71    /// CSP nonce for inline `<script>` tags (set via `webui_handler_set_nonce`).
72    nonce: Option<String>,
73}
74
75/// Opaque decoded protocol context shared across repeated host calls.
76struct ProtocolContext {
77    protocol: Protocol,
78}
79
80/// A simple string buffer for collecting rendered output.
81struct StringResponseWriter {
82    content: String,
83}
84
85impl StringResponseWriter {
86    fn new() -> Self {
87        Self {
88            content: String::new(),
89        }
90    }
91}
92
93impl ResponseWriter for StringResponseWriter {
94    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
95        self.content.push_str(content);
96        Ok(())
97    }
98
99    fn end(&mut self) -> webui_handler::Result<()> {
100        Ok(())
101    }
102}
103
104// ---------------------------------------------------------------------------
105// FFI: error reporting
106// ---------------------------------------------------------------------------
107
108/// Return the last error message, or `NULL` if no error has occurred.
109///
110/// The returned pointer is valid until the next FFI call **on the same thread**.
111/// Callers **must not** free the returned pointer.
112///
113/// # Thread Safety
114///
115/// Each thread has its own independent error state.
116#[no_mangle]
117pub extern "C" fn webui_last_error() -> *const c_char {
118    LAST_ERROR.with(|cell| {
119        let borrow = cell.borrow();
120        match borrow.as_ref() {
121            Some(c_str) => c_str.as_ptr(),
122            None => std::ptr::null(),
123        }
124    })
125}
126
127// ---------------------------------------------------------------------------
128// FFI: handler lifecycle
129// ---------------------------------------------------------------------------
130
131/// Create a new WebUI handler instance.
132///
133/// Returns an opaque pointer that must be passed to other `webui_handler_*`
134/// functions and eventually freed with [`webui_handler_destroy`].
135#[no_mangle]
136pub extern "C" fn webui_handler_create() -> *mut c_void {
137    let handler = WebUIHandler::new();
138    let context = Box::new(HandlerContext {
139        handler,
140        nonce: None,
141    });
142    Box::into_raw(context) as *mut c_void
143}
144
145/// Create a new WebUI handler instance with a named plugin.
146///
147/// # Arguments
148///
149/// * `plugin_id` - Null-terminated UTF-8 string identifying the plugin.
150///   Refer to the CLI/crate documentation for the current list of supported
151///   identifiers.
152///
153/// # Returns
154///
155/// An opaque pointer that must be freed with [`webui_handler_destroy`],
156/// or `NULL` on error (call [`webui_last_error`] for details).
157///
158/// # Safety
159///
160/// `plugin_id` must be a valid null-terminated UTF-8 string, or `NULL`.
161#[no_mangle]
162pub unsafe extern "C" fn webui_handler_create_with_plugin(plugin_id: *const c_char) -> *mut c_void {
163    clear_last_error();
164
165    let handler = if plugin_id.is_null() {
166        WebUIHandler::new()
167    } else {
168        match CStr::from_ptr(plugin_id).to_str() {
169            Ok("fast" | "fast-v2") => {
170                WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
171            }
172            Ok("fast-v3") => WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new())),
173            Ok("webui") => WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new())),
174            Ok(unknown) => {
175                set_last_error(format!(
176                    "unknown plugin: {unknown}. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
177                ));
178                return std::ptr::null_mut();
179            }
180            Err(e) => {
181                set_last_error(format!("invalid UTF-8 in plugin_id: {e}"));
182                return std::ptr::null_mut();
183            }
184        }
185    };
186
187    let context = Box::new(HandlerContext {
188        handler,
189        nonce: None,
190    });
191    Box::into_raw(context) as *mut c_void
192}
193
194/// Destroy a WebUI handler instance.
195///
196/// # Safety
197///
198/// `handler_ptr` must be a valid pointer returned by [`webui_handler_create`],
199/// or `NULL` (in which case this function is a no-op).
200#[no_mangle]
201pub unsafe extern "C" fn webui_handler_destroy(handler_ptr: *mut c_void) {
202    if !handler_ptr.is_null() {
203        let _ = Box::from_raw(handler_ptr as *mut HandlerContext);
204    }
205}
206
207/// Decode and index a WebUI protocol for repeated rendering.
208///
209/// The returned handle is thread-safe and must be released with
210/// [`webui_protocol_destroy`].
211///
212/// # Safety
213///
214/// `protocol_data` must point to `protocol_len` readable bytes.
215#[no_mangle]
216pub unsafe extern "C" fn webui_protocol_create(
217    protocol_data: *const u8,
218    protocol_len: usize,
219) -> *mut webui_protocol_t {
220    clear_last_error();
221    if protocol_data.is_null() {
222        set_last_error("protocol_data is null");
223        return std::ptr::null_mut();
224    }
225
226    match std::panic::catch_unwind(|| {
227        // SAFETY: The caller guarantees that the input range is readable.
228        let bytes = unsafe { std::slice::from_raw_parts(protocol_data, protocol_len) };
229        match Protocol::from_protobuf(bytes) {
230            Ok(protocol) => {
231                Box::into_raw(Box::new(ProtocolContext { protocol })) as *mut webui_protocol_t
232            }
233            Err(error) => {
234                set_last_error(format!("failed to parse protobuf protocol: {error}"));
235                std::ptr::null_mut()
236            }
237        }
238    }) {
239        Ok(ptr) => ptr,
240        Err(_) => {
241            set_last_error("panic in webui_protocol_create");
242            std::ptr::null_mut()
243        }
244    }
245}
246
247/// Destroy a loaded WebUI protocol handle.
248///
249/// # Safety
250///
251/// `protocol_ptr` must be a pointer returned by [`webui_protocol_create`], or
252/// `NULL` for a no-op.
253#[no_mangle]
254pub unsafe extern "C" fn webui_protocol_destroy(protocol_ptr: *mut webui_protocol_t) {
255    if !protocol_ptr.is_null() {
256        // SAFETY: The caller guarantees this pointer came from
257        // `webui_protocol_create` and has not already been destroyed.
258        let _ = unsafe { Box::from_raw(protocol_ptr as *mut ProtocolContext) };
259    }
260}
261
262/// Set the CSP nonce for inline `<script>` tags on a handler instance.
263///
264/// When set, all subsequent renders via [`webui_handler_render`] will include
265/// `nonce="VALUE"` on inline script tags and emit a
266/// `<meta name="webui-nonce" content="VALUE">` tag in the `<head>`.
267///
268/// Pass `NULL` to clear a previously set nonce.
269///
270/// # Thread Safety
271///
272/// Concurrent render calls are supported after configuration. Callers must not
273/// call `set_nonce` or destroy the handler concurrently with any operation on
274/// the same `handler_ptr`.
275///
276/// # Safety
277///
278/// * `handler_ptr` must be a valid pointer returned by [`webui_handler_create`].
279/// * `nonce` must be a valid null-terminated UTF-8 string, or `NULL`.
280/// * Caller must ensure exclusive access to `handler_ptr` (no concurrent calls).
281#[no_mangle]
282pub unsafe extern "C" fn webui_handler_set_nonce(handler_ptr: *mut c_void, nonce: *const c_char) {
283    clear_last_error();
284
285    if handler_ptr.is_null() {
286        set_last_error("handler_ptr is null");
287        return;
288    }
289
290    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
291        // SAFETY: caller guarantees handler_ptr is valid and exclusively owned.
292        let context = &mut *(handler_ptr as *mut HandlerContext);
293
294        if nonce.is_null() {
295            context.nonce = None;
296        } else {
297            // SAFETY: caller guarantees nonce is a valid null-terminated string.
298            match CStr::from_ptr(nonce).to_str() {
299                Ok(s) => context.nonce = Some(s.to_string()),
300                Err(e) => {
301                    set_last_error(format!("invalid UTF-8 in nonce: {e}"));
302                }
303            }
304        }
305    })) {
306        Ok(()) => {}
307        Err(_) => {
308            set_last_error("panic in webui_handler_set_nonce");
309        }
310    }
311}
312
313// ---------------------------------------------------------------------------
314// FFI: protocol rendering
315// ---------------------------------------------------------------------------
316
317/// Render using a protocol previously returned by [`webui_protocol_create`].
318///
319/// # Safety
320///
321/// * `handler_ptr` must be a valid handler pointer.
322/// * `protocol_ptr` must be a valid loaded protocol pointer.
323/// * String arguments must be valid null-terminated UTF-8.
324#[no_mangle]
325pub unsafe extern "C" fn webui_handler_render(
326    handler_ptr: *mut c_void,
327    protocol_ptr: *const webui_protocol_t,
328    data_json: *const c_char,
329    entry_id: *const c_char,
330    request_path: *const c_char,
331) -> *mut c_char {
332    clear_last_error();
333
334    match std::panic::catch_unwind(|| {
335        if handler_ptr.is_null()
336            || protocol_ptr.is_null()
337            || data_json.is_null()
338            || entry_id.is_null()
339            || request_path.is_null()
340        {
341            set_last_error("one or more required arguments are null");
342            return std::ptr::null_mut();
343        }
344
345        // SAFETY: The caller guarantees both opaque pointers are valid.
346        let context = unsafe { &*(handler_ptr as *const HandlerContext) };
347        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
348        // SAFETY: The caller guarantees all string pointers are valid.
349        unsafe {
350            render_decoded_protocol(
351                context,
352                &protocol_context.protocol,
353                data_json,
354                entry_id,
355                request_path,
356            )
357        }
358    }) {
359        Ok(ptr) => ptr,
360        Err(_) => {
361            set_last_error("panic in webui_handler_render");
362            std::ptr::null_mut()
363        }
364    }
365}
366
367unsafe fn render_decoded_protocol(
368    context: &HandlerContext,
369    protocol: &Protocol,
370    data_json: *const c_char,
371    entry_id: *const c_char,
372    request_path: *const c_char,
373) -> *mut c_char {
374    // SAFETY: The caller validates all pointers before invoking this helper.
375    let data_str = match unsafe { CStr::from_ptr(data_json) }.to_str() {
376        Ok(s) => s,
377        Err(e) => {
378            set_last_error(format!("invalid UTF-8 in data_json: {e}"));
379            return std::ptr::null_mut();
380        }
381    };
382    // SAFETY: The caller validates all pointers before invoking this helper.
383    let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
384        Ok(s) => s,
385        Err(e) => {
386            set_last_error(format!("invalid UTF-8 in entry_id: {e}"));
387            return std::ptr::null_mut();
388        }
389    };
390    // SAFETY: The caller validates all pointers before invoking this helper.
391    let path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
392        Ok(s) => s,
393        Err(e) => {
394            set_last_error(format!("invalid UTF-8 in request_path: {e}"));
395            return std::ptr::null_mut();
396        }
397    };
398
399    let data: Value = match serde_json::from_str(data_str) {
400        Ok(d) => d,
401        Err(e) => {
402            set_last_error(format!("failed to parse data JSON: {e}"));
403            return std::ptr::null_mut();
404        }
405    };
406
407    // Render
408    let mut options = RenderOptions::new(entry_str, path_str);
409    if let Some(ref nonce) = context.nonce {
410        options = options.with_nonce(nonce);
411    }
412
413    let mut writer = StringResponseWriter::new();
414    match context
415        .handler
416        .render(protocol, &data, &options, &mut writer)
417    {
418        Ok(_) => match CString::new(writer.content) {
419            Ok(s) => s.into_raw(),
420            Err(e) => {
421                set_last_error(format!("rendered output contains interior NUL byte: {e}"));
422                std::ptr::null_mut()
423            }
424        },
425        Err(e) => {
426            set_last_error(format!("render failed: {e}"));
427            std::ptr::null_mut()
428        }
429    }
430}
431
432// ---------------------------------------------------------------------------
433// FFI: unified partial response
434// ---------------------------------------------------------------------------
435
436/// Produce a complete partial response using a loaded protocol handle.
437///
438/// # Safety
439///
440/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
441/// * All string pointers must be valid, non-null, null-terminated UTF-8.
442#[no_mangle]
443pub unsafe extern "C" fn webui_protocol_render_partial(
444    protocol_ptr: *const webui_protocol_t,
445    state_json: *const c_char,
446    entry_id: *const c_char,
447    request_path: *const c_char,
448    inventory_hex: *const c_char,
449) -> *mut c_char {
450    clear_last_error();
451
452    match std::panic::catch_unwind(|| {
453        if protocol_ptr.is_null()
454            || state_json.is_null()
455            || entry_id.is_null()
456            || request_path.is_null()
457            || inventory_hex.is_null()
458        {
459            set_last_error("one or more required arguments are null");
460            return std::ptr::null_mut();
461        }
462
463        // SAFETY: The caller guarantees that all opaque/string pointers are valid.
464        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
465        let state_str = match unsafe { CStr::from_ptr(state_json) }.to_str() {
466            Ok(value) => value,
467            Err(error) => {
468                set_last_error(format!("invalid UTF-8 in state_json: {error}"));
469                return std::ptr::null_mut();
470            }
471        };
472        let entry_str = match unsafe { CStr::from_ptr(entry_id) }.to_str() {
473            Ok(value) => value,
474            Err(error) => {
475                set_last_error(format!("invalid UTF-8 in entry_id: {error}"));
476                return std::ptr::null_mut();
477            }
478        };
479        let request_path_str = match unsafe { CStr::from_ptr(request_path) }.to_str() {
480            Ok(value) => value,
481            Err(error) => {
482                set_last_error(format!("invalid UTF-8 in request_path: {error}"));
483                return std::ptr::null_mut();
484            }
485        };
486        let inventory_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
487            Ok(value) => value,
488            Err(error) => {
489                set_last_error(format!("invalid UTF-8 in inventory_hex: {error}"));
490                return std::ptr::null_mut();
491            }
492        };
493
494        let output = match protocol_context.protocol.render_partial(
495            state_str,
496            entry_str,
497            request_path_str,
498            inventory_str,
499        ) {
500            Ok(value) => value,
501            Err(error) => {
502                set_last_error(format!("render_partial failed: {error}"));
503                return std::ptr::null_mut();
504            }
505        };
506
507        match CString::new(output) {
508            Ok(value) => value.into_raw(),
509            Err(error) => {
510                set_last_error(format!("JSON output contains interior NUL byte: {error}"));
511                std::ptr::null_mut()
512            }
513        }
514    }) {
515        Ok(ptr) => ptr,
516        Err(_) => {
517            set_last_error("panic in webui_protocol_render_partial");
518            std::ptr::null_mut()
519        }
520    }
521}
522
523/// Render component templates using a loaded protocol handle.
524///
525/// # Safety
526///
527/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
528/// * String arguments must be valid, non-null, null-terminated UTF-8.
529#[no_mangle]
530pub unsafe extern "C" fn webui_protocol_render_component_templates(
531    protocol_ptr: *const webui_protocol_t,
532    component_tags_json: *const c_char,
533    inventory_hex: *const c_char,
534) -> *mut c_char {
535    clear_last_error();
536
537    match std::panic::catch_unwind(|| {
538        if protocol_ptr.is_null() || component_tags_json.is_null() || inventory_hex.is_null() {
539            set_last_error("one or more required arguments are null");
540            return std::ptr::null_mut();
541        }
542
543        // SAFETY: The caller guarantees that all opaque/string pointers are valid.
544        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
545        let tags_str = match unsafe { CStr::from_ptr(component_tags_json) }.to_str() {
546            Ok(value) => value,
547            Err(error) => {
548                set_last_error(format!("invalid UTF-8 in component_tags_json: {error}"));
549                return std::ptr::null_mut();
550            }
551        };
552        let tags: Vec<String> = match serde_json::from_str(tags_str) {
553            Ok(value) => value,
554            Err(error) => {
555                set_last_error(format!("invalid tags JSON: {error}"));
556                return std::ptr::null_mut();
557            }
558        };
559        let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
560        let inventory_str = match unsafe { CStr::from_ptr(inventory_hex) }.to_str() {
561            Ok(value) => value,
562            Err(error) => {
563                set_last_error(format!("invalid UTF-8 in inventory_hex: {error}"));
564                return std::ptr::null_mut();
565            }
566        };
567
568        let result = match protocol_context
569            .protocol
570            .render_component_templates(&tag_refs, inventory_str)
571        {
572            Ok(value) => value,
573            Err(error) => {
574                set_last_error(format!("render_component_templates failed: {error}"));
575                return std::ptr::null_mut();
576            }
577        };
578
579        match CString::new(result.to_string()) {
580            Ok(value) => value.into_raw(),
581            Err(error) => {
582                set_last_error(format!("JSON output contains interior NUL byte: {error}"));
583                std::ptr::null_mut()
584            }
585        }
586    }) {
587        Ok(ptr) => ptr,
588        Err(_) => {
589            set_last_error("panic in webui_protocol_render_component_templates");
590            std::ptr::null_mut()
591        }
592    }
593}
594
595/// Free a string returned by a WebUI FFI function.
596///
597/// # Safety
598///
599/// `string_ptr` must be a pointer returned by a WebUI FFI function such as
600/// [`webui_handler_render`], or `NULL`
601/// (in which case this function is a no-op).
602#[no_mangle]
603pub unsafe extern "C" fn webui_free(string_ptr: *mut c_char) {
604    if !string_ptr.is_null() {
605        let _ = CString::from_raw(string_ptr);
606    }
607}
608
609/// Extract CSS token names from a loaded protocol handle.
610///
611/// Returns a newline-delimited representation.
612///
613/// # Safety
614///
615/// * `protocol_ptr` must be a valid pointer returned by [`webui_protocol_create`].
616/// * The returned pointer must be freed with [`webui_free`].
617#[no_mangle]
618pub unsafe extern "C" fn webui_protocol_tokens(
619    protocol_ptr: *const webui_protocol_t,
620) -> *mut c_char {
621    clear_last_error();
622
623    match std::panic::catch_unwind(|| {
624        if protocol_ptr.is_null() {
625            set_last_error("protocol_ptr is null");
626            return std::ptr::null_mut();
627        }
628
629        // SAFETY: The caller guarantees protocol_ptr is a live loaded handle.
630        let protocol_context = unsafe { &*(protocol_ptr as *const ProtocolContext) };
631        let joined = protocol_context.protocol.tokens().join("\n");
632
633        match CString::new(joined) {
634            Ok(value) => value.into_raw(),
635            Err(error) => {
636                set_last_error(format!("token string contains null byte: {error}"));
637                std::ptr::null_mut()
638            }
639        }
640    }) {
641        Ok(ptr) => ptr,
642        Err(_) => {
643            set_last_error("panic in webui_protocol_tokens");
644            std::ptr::null_mut()
645        }
646    }
647}