bext-php 0.2.0

Embedded PHP runtime for bext — custom SAPI linking libphp via Rust FFI
Documentation
//! Per-request context for PHP script execution.
//!
//! `RequestCtx` holds all Rust-side state for a single PHP request.
//! Passed to C as an opaque pointer — SAPI callbacks cast back to access it.

use std::ffi::CString;

/// Parameters for resetting a request context in worker mode.
pub struct WorkerRequestParams<'a> {
    pub request_body: Vec<u8>,
    pub cookie_header: Option<&'a str>,
    pub request_headers: Vec<(String, String)>,
    pub method: &'a str,
    pub uri: &'a str,
    pub query_string: &'a str,
    pub content_type: Option<&'a str>,
    pub remote_addr: Option<&'a str>,
    pub server_name: Option<&'a str>,
    pub server_port: u16,
    pub https: bool,
}

/// Rust-side state for a single PHP request execution.
///
/// # Safety
/// Must remain pinned in memory for the duration of the PHP script execution.
/// Owned exclusively by one worker thread — no concurrent access.
pub struct RequestCtx {
    // ─── Output ──────────────────────────────────────────────────────────
    /// Accumulated PHP output (echo, print, etc.).
    pub output_buf: Vec<u8>,

    // ─── Input ───────────────────────────────────────────────────────────
    /// Request body (POST/PUT data).
    pub request_body: Vec<u8>,
    /// Current read position in request_body (for streaming reads).
    pub body_read_pos: usize,

    // ─── Headers ─────────────────────────────────────────────────────────
    /// Raw Cookie header (CString for C lifetime).
    pub cookie_header: Option<CString>,
    /// Response headers from PHP header() calls.
    pub response_headers: Vec<(String, String)>,
    /// HTTP status code set by PHP.
    pub status_code: u16,
    /// Incoming HTTP request headers for $_SERVER.
    pub request_headers: Vec<(String, String)>,

    // ─── Server info ─────────────────────────────────────────────────────
    /// Client IP (REMOTE_ADDR).
    pub remote_addr: Option<CString>,
    /// Server hostname (SERVER_NAME / HTTP_HOST).
    pub server_name: Option<CString>,
    /// Server port.
    pub server_port: u16,
    /// HTTPS flag.
    pub https: bool,
    /// Document root path.
    pub document_root: Option<String>,

    // ─── CString fields for worker mode request info accessors ───────────
    // These must live as long as the request because C reads them as pointers.
    pub c_method: Option<CString>,
    pub c_uri: Option<CString>,
    pub c_query_string: Option<CString>,
    pub c_content_type: Option<CString>,
}

impl RequestCtx {
    /// Create a new request context with full HTTP metadata.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        request_body: Vec<u8>,
        cookie_header: Option<&str>,
        request_headers: Vec<(String, String)>,
        remote_addr: Option<&str>,
        server_name: Option<&str>,
        server_port: u16,
        https: bool,
    ) -> Self {
        Self {
            output_buf: Vec::with_capacity(8192),
            request_body,
            body_read_pos: 0,
            cookie_header: cookie_header.and_then(|c| {
                CString::new(c).ok().or_else(|| {
                    tracing::warn!("Cookie header contains null bytes — dropped");
                    None
                })
            }),
            response_headers: Vec::new(),
            status_code: 200,
            request_headers,
            remote_addr: remote_addr.and_then(|s| CString::new(s).ok()),
            server_name: server_name.and_then(|s| CString::new(s).ok()),
            server_port,
            https,
            document_root: None,
            c_method: None,
            c_uri: None,
            c_query_string: None,
            c_content_type: None,
        }
    }

    /// Set request info fields (method, URI, query, content_type) as CStrings.
    /// These are read by C via the bext_sapi_get_* accessor callbacks.
    pub fn set_request_info(
        &mut self,
        method: &str,
        uri: &str,
        query_string: &str,
        content_type: Option<&str>,
    ) {
        self.c_method = CString::new(method).ok();
        self.c_uri = CString::new(uri).ok();
        self.c_query_string = CString::new(query_string).ok();
        self.c_content_type = content_type.and_then(|ct| CString::new(ct).ok());
    }

    /// Reset for the next request in worker mode.
    /// Clears output and response state but preserves connection metadata.
    pub fn reset_for_worker_request(&mut self, p: WorkerRequestParams<'_>) {
        self.output_buf.clear();
        self.request_body = p.request_body;
        self.body_read_pos = 0;
        self.cookie_header = p.cookie_header.and_then(|c| CString::new(c).ok());
        self.response_headers.clear();
        self.status_code = 200;
        self.request_headers = p.request_headers;
        self.remote_addr = p.remote_addr.and_then(|s| CString::new(s).ok());
        self.server_name = p.server_name.and_then(|s| CString::new(s).ok());
        self.server_port = p.server_port;
        self.https = p.https;
        self.set_request_info(p.method, p.uri, p.query_string, p.content_type);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_ctx() -> RequestCtx {
        RequestCtx::new(Vec::new(), None, Vec::new(), None, None, 80, false)
    }

    fn make_ctx_full() -> RequestCtx {
        RequestCtx::new(
            b"name=test&value=42".to_vec(),
            Some("session_id=abc123; theme=dark"),
            vec![
                ("Accept".into(), "text/html".into()),
                ("User-Agent".into(), "bext-test/1.0".into()),
            ],
            Some("192.168.1.100"),
            Some("example.com"),
            443,
            true,
        )
    }

    #[test]
    fn new_empty_context() {
        let ctx = make_ctx();
        assert!(ctx.output_buf.is_empty());
        assert!(ctx.request_body.is_empty());
        assert_eq!(ctx.body_read_pos, 0);
        assert!(ctx.cookie_header.is_none());
        assert!(ctx.response_headers.is_empty());
        assert_eq!(ctx.status_code, 200);
        assert!(ctx.request_headers.is_empty());
        assert!(ctx.remote_addr.is_none());
        assert!(ctx.server_name.is_none());
        assert_eq!(ctx.server_port, 80);
        assert!(!ctx.https);
        assert!(ctx.c_method.is_none());
        assert!(ctx.c_uri.is_none());
    }

    #[test]
    fn new_full_context() {
        let ctx = make_ctx_full();
        assert_eq!(ctx.request_body, b"name=test&value=42");
        assert!(ctx.cookie_header.is_some());
        assert_eq!(ctx.request_headers.len(), 2);
        assert_eq!(
            ctx.remote_addr.as_ref().unwrap().to_str().unwrap(),
            "192.168.1.100"
        );
        assert_eq!(
            ctx.server_name.as_ref().unwrap().to_str().unwrap(),
            "example.com"
        );
        assert_eq!(ctx.server_port, 443);
        assert!(ctx.https);
    }

    #[test]
    fn set_request_info() {
        let mut ctx = make_ctx();
        ctx.set_request_info("POST", "/api/data", "page=1", Some("application/json"));
        assert_eq!(ctx.c_method.as_ref().unwrap().to_str().unwrap(), "POST");
        assert_eq!(ctx.c_uri.as_ref().unwrap().to_str().unwrap(), "/api/data");
        assert_eq!(
            ctx.c_query_string.as_ref().unwrap().to_str().unwrap(),
            "page=1"
        );
        assert_eq!(
            ctx.c_content_type.as_ref().unwrap().to_str().unwrap(),
            "application/json"
        );
    }

    #[test]
    fn reset_for_worker_request() {
        let mut ctx = make_ctx_full();
        ctx.output_buf.extend_from_slice(b"old output");
        ctx.response_headers.push(("X-Old".into(), "val".into()));

        ctx.reset_for_worker_request(WorkerRequestParams {
            request_body: b"new body".to_vec(),
            cookie_header: Some("new_cookie=x"),
            request_headers: vec![("Accept".into(), "application/json".into())],
            method: "PUT",
            uri: "/api/update",
            query_string: "id=42",
            content_type: Some("application/json"),
            remote_addr: Some("10.0.0.1"),
            server_name: Some("api.example.com"),
            server_port: 8080,
            https: false,
        });

        assert!(ctx.output_buf.is_empty());
        assert_eq!(ctx.request_body, b"new body");
        assert_eq!(ctx.body_read_pos, 0);
        assert!(ctx.response_headers.is_empty());
        assert_eq!(ctx.status_code, 200);
        assert_eq!(ctx.c_method.as_ref().unwrap().to_str().unwrap(), "PUT");
        assert_eq!(ctx.server_port, 8080);
        assert!(!ctx.https);
    }

    #[test]
    fn output_buf_accumulates() {
        let mut ctx = make_ctx();
        ctx.output_buf.extend_from_slice(b"<html>");
        ctx.output_buf.extend_from_slice(b"<body>Hello</body>");
        ctx.output_buf.extend_from_slice(b"</html>");
        assert_eq!(
            String::from_utf8_lossy(&ctx.output_buf),
            "<html><body>Hello</body></html>"
        );
    }

    #[test]
    fn cookie_header_null_bytes_filtered() {
        let ctx = RequestCtx::new(
            Vec::new(),
            Some("bad\0cookie"),
            Vec::new(),
            None,
            None,
            80,
            false,
        );
        assert!(ctx.cookie_header.is_none());
    }

    #[test]
    fn large_body() {
        let body = vec![0x42u8; 1024 * 1024];
        let ctx = RequestCtx::new(body, None, Vec::new(), None, None, 80, false);
        assert_eq!(ctx.request_body.len(), 1024 * 1024);
    }

    #[test]
    fn many_headers() {
        let headers: Vec<(String, String)> = (0..50)
            .map(|i| (format!("X-Header-{}", i), format!("value-{}", i)))
            .collect();
        let ctx = RequestCtx::new(Vec::new(), None, headers, None, None, 80, false);
        assert_eq!(ctx.request_headers.len(), 50);
    }
}