bext-php 0.2.0

Embedded PHP runtime for bext — custom SAPI linking libphp via Rust FFI
Documentation
//! Raw FFI bindings to the C SAPI bridge.
//!
//! Two sets of functions:
//!   1. extern "C" { ... } — C functions we call from Rust
//!   2. #[no_mangle] extern "C" fn — Rust callbacks called from C

use std::ffi::CString;
use std::os::raw::{c_char, c_int, c_void};

#[repr(C)]
pub struct BextRequestCtx {
    _opaque: [u8; 0],
}

// ---------------------------------------------------------------------------
// C functions we call from Rust
// ---------------------------------------------------------------------------

extern "C" {
    pub fn bext_php_module_init(ini_entries: *const c_char) -> c_int;
    pub fn bext_php_module_shutdown();

    /// Classic mode: execute a single PHP script.
    pub fn bext_php_execute_script(
        ctx: *mut BextRequestCtx,
        script_path: *const c_char,
        method: *const c_char,
        uri: *const c_char,
        query_string: *const c_char,
        content_type: *const c_char,
        content_length: i64,
    ) -> c_int;

    /// Worker mode: execute a worker script that loops on bext_handle_request().
    pub fn bext_php_execute_worker(
        initial_ctx: *mut BextRequestCtx,
        worker_script_path: *const c_char,
    ) -> c_int;

    pub fn bext_php_register_variable(
        key: *const c_char,
        val: *const c_char,
        track_vars_array: *mut c_void,
    );
}

// ---------------------------------------------------------------------------
// Rust callbacks (called from C)
// ---------------------------------------------------------------------------

use super::context::RequestCtx;

const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256MB

/// PHP output — echo, print, etc.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_ub_write(
    ctx: *mut BextRequestCtx,
    str_ptr: *const c_char,
    len: usize,
) -> usize {
    if ctx.is_null() || str_ptr.is_null() {
        return 0;
    }
    let req_ctx = &mut *(ctx as *mut RequestCtx);
    if req_ctx.output_buf.len().saturating_add(len) > MAX_OUTPUT_BYTES {
        return 0;
    }
    let bytes = std::slice::from_raw_parts(str_ptr as *const u8, len);
    req_ctx.output_buf.extend_from_slice(bytes);
    len
}

/// POST body read.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_read_post(
    ctx: *mut BextRequestCtx,
    buf: *mut c_char,
    count: usize,
) -> usize {
    if ctx.is_null() || buf.is_null() {
        return 0;
    }
    let req_ctx = &mut *(ctx as *mut RequestCtx);
    let remaining = &req_ctx.request_body[req_ctx.body_read_pos..];
    let to_read = remaining.len().min(count);
    if to_read > 0 {
        std::ptr::copy_nonoverlapping(remaining.as_ptr(), buf as *mut u8, to_read);
        req_ctx.body_read_pos += to_read;
    }
    to_read
}

/// Cookie header.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_read_cookies(ctx: *mut BextRequestCtx) -> *mut c_char {
    if ctx.is_null() {
        return std::ptr::null_mut();
    }
    let req_ctx = &mut *(ctx as *mut RequestCtx);
    match &req_ctx.cookie_header {
        Some(c) => c.as_ptr() as *mut c_char,
        None => std::ptr::null_mut(),
    }
}

/// Capture PHP header() calls.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_on_header(
    ctx: *mut BextRequestCtx,
    header: *const c_char,
    header_len: usize,
) {
    if ctx.is_null() || header.is_null() || header_len == 0 {
        return;
    }
    let req_ctx = &mut *(ctx as *mut RequestCtx);
    let bytes = std::slice::from_raw_parts(header as *const u8, header_len);
    let header_str = String::from_utf8_lossy(bytes);
    if let Some(colon_pos) = header_str.find(": ") {
        let name = header_str[..colon_pos].to_string();
        let value = header_str[colon_pos + 2..].to_string();
        // Defense-in-depth: reject headers containing CRLF or null bytes to
        // prevent HTTP response splitting. Actix-web also validates downstream,
        // but we filter at the FFI boundary to catch it early.
        if name.contains('\r') || name.contains('\n') || name.contains('\0')
            || value.contains('\r') || value.contains('\n') || value.contains('\0')
        {
            return;
        }
        req_ctx.response_headers.push((name, value));
    }
}

/// Populate $_SERVER.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_register_server_variables(
    ctx: *mut BextRequestCtx,
    track_vars_array: *mut c_void,
) {
    if ctx.is_null() || track_vars_array.is_null() {
        return;
    }
    let req_ctx = &*(ctx as *const RequestCtx);

    let reg = |key: &str, val: &str| {
        if let (Ok(k), Ok(v)) = (CString::new(key), CString::new(val)) {
            bext_php_register_variable(k.as_ptr(), v.as_ptr(), track_vars_array);
        }
    };

    reg("SERVER_SOFTWARE", "bext-php/0.1");
    reg("GATEWAY_INTERFACE", "CGI/1.1");
    reg("SERVER_PROTOCOL", "HTTP/1.1");

    if let Some(ref name) = req_ctx.server_name {
        let s = name.to_string_lossy();
        reg("SERVER_NAME", &s);
        reg("HTTP_HOST", &s);
    }
    reg("SERVER_PORT", &req_ctx.server_port.to_string());

    if let Some(ref addr) = req_ctx.remote_addr {
        let s = addr.to_string_lossy();
        reg("REMOTE_ADDR", &s);
        reg("REMOTE_HOST", &s);
    }

    if req_ctx.https {
        reg("HTTPS", "on");
    }

    // Map request headers → HTTP_* variables
    for (name, value) in &req_ctx.request_headers {
        let var_name = format!("HTTP_{}", name.to_uppercase().replace('-', "_"));
        reg(&var_name, value);
    }

    // DOCUMENT_ROOT
    if let Some(ref doc_root) = req_ctx.document_root {
        reg("DOCUMENT_ROOT", doc_root);
    }
}

/// PHP log message → tracing.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_log_message(message: *const c_char, syslog_type: c_int) {
    if message.is_null() {
        return;
    }
    let msg = std::ffi::CStr::from_ptr(message);
    let msg_str = msg.to_string_lossy();
    match syslog_type {
        0..=3 => tracing::error!(target: "php", "{}", msg_str),
        4 => tracing::warn!(target: "php", "{}", msg_str),
        5..=6 => tracing::info!(target: "php", "{}", msg_str),
        _ => tracing::debug!(target: "php", "{}", msg_str),
    }
}

// ---------------------------------------------------------------------------
// Worker mode callbacks
// ---------------------------------------------------------------------------

use super::pool::WORKER_THREAD_STATE;

/// Called from C when bext_handle_request() needs the next request.
/// Blocks until Rust dispatches one or signals shutdown.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_worker_wait_request(ctx_out: *mut *mut BextRequestCtx) -> c_int {
    WORKER_THREAD_STATE.with(|state| {
        let state = state.borrow();
        if let Some(ref wts) = *state {
            match wts.request_rx.recv() {
                Ok(send_ptr) => {
                    *ctx_out = send_ptr.0 as *mut BextRequestCtx;
                    1
                }
                Err(_) => 0,
            }
        } else {
            0
        }
    })
}

/// Called from C when bext_handle_request() finishes processing.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_worker_finish_request(ctx: *mut BextRequestCtx, status: c_int) {
    if ctx.is_null() {
        return;
    }
    let req_ctx = &mut *(ctx as *mut RequestCtx);
    req_ctx.status_code = status as u16;

    WORKER_THREAD_STATE.with(|state| {
        let state = state.borrow();
        if let Some(ref wts) = *state {
            let _ = wts.done_tx.send(());
        }
    });
}

// ---------------------------------------------------------------------------
// Worker mode: request info accessors (C calls these to populate SG())
//
// These return pointers to CString fields inside RequestCtx.  The pointers
// remain valid for the lifetime of the request (the Box is kept alive in
// the dispatcher thread until done_tx fires).
//
// Fallback values are static constants — never stack-allocated.
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Shared memory bridge: bext_render() PHP function → JSC pool
// ---------------------------------------------------------------------------

/// Called from C when PHP calls bext_render($component, $props_json).
/// Dispatches to JSC pool via the bridge module, returns HTML.
///
/// # Safety
/// `component` and `props_json` must be valid null-terminated C strings.
/// The returned pointer is a Rust-allocated CString that must be freed
/// by the caller via `bext_sapi_free_string`.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_jsc_render(
    component: *const c_char,
    props_json: *const c_char,
) -> *mut c_char {
    if component.is_null() || props_json.is_null() {
        let err = CString::new("<div>bext_render: null argument</div>").unwrap();
        return err.into_raw();
    }

    let comp_str = std::ffi::CStr::from_ptr(component)
        .to_string_lossy()
        .into_owned();
    let props_str = std::ffi::CStr::from_ptr(props_json)
        .to_string_lossy()
        .into_owned();

    // Validate component name: alphanumeric + underscore + hyphen only (prevents injection)
    if !comp_str
        .chars()
        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
    {
        let err = CString::new("<div style=\"color:red\">Invalid component name</div>").unwrap();
        return err.into_raw();
    }

    // Build props safely using serde_json to prevent JSON injection
    let full_props = match serde_json::from_str::<serde_json::Value>(&props_str) {
        Ok(mut obj) => {
            if let Some(map) = obj.as_object_mut() {
                map.insert(
                    "_component".to_string(),
                    serde_json::Value::String(comp_str.clone()),
                );
            }
            serde_json::to_string(&obj)
                .unwrap_or_else(|_| format!("{{\"_component\":\"{}\"}}", comp_str))
        }
        Err(_) => {
            format!("{{\"_component\":\"{}\"}}", comp_str)
        }
    };

    let html = super::bridge::jsc_render(&full_props);

    match CString::new(html) {
        Ok(cs) => cs.into_raw(),
        Err(_) => {
            let err = CString::new("<div>bext_render: null byte in HTML</div>").unwrap();
            err.into_raw()
        }
    }
}

/// Free a string returned by bext_sapi_jsc_render.
#[no_mangle]
pub unsafe extern "C" fn bext_sapi_free_string(ptr: *mut c_char) {
    if !ptr.is_null() {
        let _ = CString::from_raw(ptr);
    }
}

static FALLBACK_GET: &[u8] = b"GET\0";
static FALLBACK_SLASH: &[u8] = b"/\0";
static FALLBACK_EMPTY: &[u8] = b"\0";

#[no_mangle]
pub unsafe extern "C" fn bext_sapi_get_method(ctx: *mut BextRequestCtx) -> *const c_char {
    if ctx.is_null() {
        return FALLBACK_GET.as_ptr() as *const c_char;
    }
    let req_ctx = &*(ctx as *const RequestCtx);
    req_ctx
        .c_method
        .as_ref()
        .map(|s| s.as_ptr())
        .unwrap_or(FALLBACK_GET.as_ptr() as *const c_char)
}

#[no_mangle]
pub unsafe extern "C" fn bext_sapi_get_uri(ctx: *mut BextRequestCtx) -> *const c_char {
    if ctx.is_null() {
        return FALLBACK_SLASH.as_ptr() as *const c_char;
    }
    let req_ctx = &*(ctx as *const RequestCtx);
    req_ctx
        .c_uri
        .as_ref()
        .map(|s| s.as_ptr())
        .unwrap_or(FALLBACK_SLASH.as_ptr() as *const c_char)
}

#[no_mangle]
pub unsafe extern "C" fn bext_sapi_get_query_string(ctx: *mut BextRequestCtx) -> *const c_char {
    if ctx.is_null() {
        return FALLBACK_EMPTY.as_ptr() as *const c_char;
    }
    let req_ctx = &*(ctx as *const RequestCtx);
    req_ctx
        .c_query_string
        .as_ref()
        .map(|s| s.as_ptr())
        .unwrap_or(FALLBACK_EMPTY.as_ptr() as *const c_char)
}

#[no_mangle]
pub unsafe extern "C" fn bext_sapi_get_content_type(ctx: *mut BextRequestCtx) -> *const c_char {
    if ctx.is_null() {
        return std::ptr::null();
    }
    let req_ctx = &*(ctx as *const RequestCtx);
    req_ctx
        .c_content_type
        .as_ref()
        .map(|s| s.as_ptr())
        .unwrap_or(std::ptr::null())
}

#[no_mangle]
pub unsafe extern "C" fn bext_sapi_get_content_length(ctx: *mut BextRequestCtx) -> i64 {
    if ctx.is_null() {
        return 0;
    }
    let req_ctx = &*(ctx as *const RequestCtx);
    req_ctx.request_body.len() as i64
}