Skip to main content

bext_php/
context.rs

1//! Per-request context for PHP script execution.
2//!
3//! `RequestCtx` holds all Rust-side state for a single PHP request.
4//! Passed to C as an opaque pointer — SAPI callbacks cast back to access it.
5
6use std::ffi::CString;
7
8/// Parameters for resetting a request context in worker mode.
9pub struct WorkerRequestParams<'a> {
10    pub request_body: Vec<u8>,
11    pub cookie_header: Option<&'a str>,
12    pub request_headers: Vec<(String, String)>,
13    pub method: &'a str,
14    pub uri: &'a str,
15    pub query_string: &'a str,
16    pub content_type: Option<&'a str>,
17    pub remote_addr: Option<&'a str>,
18    pub server_name: Option<&'a str>,
19    pub server_port: u16,
20    pub https: bool,
21}
22
23/// Rust-side state for a single PHP request execution.
24///
25/// # Safety
26/// Must remain pinned in memory for the duration of the PHP script execution.
27/// Owned exclusively by one worker thread — no concurrent access.
28pub struct RequestCtx {
29    // ─── Output ──────────────────────────────────────────────────────────
30    /// Accumulated PHP output (echo, print, etc.).
31    pub output_buf: Vec<u8>,
32
33    // ─── Input ───────────────────────────────────────────────────────────
34    /// Request body (POST/PUT data).
35    pub request_body: Vec<u8>,
36    /// Current read position in request_body (for streaming reads).
37    pub body_read_pos: usize,
38
39    // ─── Headers ─────────────────────────────────────────────────────────
40    /// Raw Cookie header (CString for C lifetime).
41    pub cookie_header: Option<CString>,
42    /// Response headers from PHP header() calls.
43    pub response_headers: Vec<(String, String)>,
44    /// HTTP status code set by PHP.
45    pub status_code: u16,
46    /// Incoming HTTP request headers for $_SERVER.
47    pub request_headers: Vec<(String, String)>,
48
49    // ─── Server info ─────────────────────────────────────────────────────
50    /// Client IP (REMOTE_ADDR).
51    pub remote_addr: Option<CString>,
52    /// Server hostname (SERVER_NAME / HTTP_HOST).
53    pub server_name: Option<CString>,
54    /// Server port.
55    pub server_port: u16,
56    /// HTTPS flag.
57    pub https: bool,
58    /// Document root path.
59    pub document_root: Option<String>,
60
61    // ─── CString fields for worker mode request info accessors ───────────
62    // These must live as long as the request because C reads them as pointers.
63    pub c_method: Option<CString>,
64    pub c_uri: Option<CString>,
65    pub c_query_string: Option<CString>,
66    pub c_content_type: Option<CString>,
67}
68
69impl RequestCtx {
70    /// Create a new request context with full HTTP metadata.
71    #[allow(clippy::too_many_arguments)]
72    pub fn new(
73        request_body: Vec<u8>,
74        cookie_header: Option<&str>,
75        request_headers: Vec<(String, String)>,
76        remote_addr: Option<&str>,
77        server_name: Option<&str>,
78        server_port: u16,
79        https: bool,
80    ) -> Self {
81        Self {
82            output_buf: Vec::with_capacity(8192),
83            request_body,
84            body_read_pos: 0,
85            cookie_header: cookie_header.and_then(|c| {
86                CString::new(c).ok().or_else(|| {
87                    tracing::warn!("Cookie header contains null bytes — dropped");
88                    None
89                })
90            }),
91            response_headers: Vec::new(),
92            status_code: 200,
93            request_headers,
94            remote_addr: remote_addr.and_then(|s| CString::new(s).ok()),
95            server_name: server_name.and_then(|s| CString::new(s).ok()),
96            server_port,
97            https,
98            document_root: None,
99            c_method: None,
100            c_uri: None,
101            c_query_string: None,
102            c_content_type: None,
103        }
104    }
105
106    /// Set request info fields (method, URI, query, content_type) as CStrings.
107    /// These are read by C via the bext_sapi_get_* accessor callbacks.
108    pub fn set_request_info(
109        &mut self,
110        method: &str,
111        uri: &str,
112        query_string: &str,
113        content_type: Option<&str>,
114    ) {
115        self.c_method = CString::new(method).ok();
116        self.c_uri = CString::new(uri).ok();
117        self.c_query_string = CString::new(query_string).ok();
118        self.c_content_type = content_type.and_then(|ct| CString::new(ct).ok());
119    }
120
121    /// Reset for the next request in worker mode.
122    /// Clears output and response state but preserves connection metadata.
123    pub fn reset_for_worker_request(&mut self, p: WorkerRequestParams<'_>) {
124        self.output_buf.clear();
125        self.request_body = p.request_body;
126        self.body_read_pos = 0;
127        self.cookie_header = p.cookie_header.and_then(|c| CString::new(c).ok());
128        self.response_headers.clear();
129        self.status_code = 200;
130        self.request_headers = p.request_headers;
131        self.remote_addr = p.remote_addr.and_then(|s| CString::new(s).ok());
132        self.server_name = p.server_name.and_then(|s| CString::new(s).ok());
133        self.server_port = p.server_port;
134        self.https = p.https;
135        self.set_request_info(p.method, p.uri, p.query_string, p.content_type);
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn make_ctx() -> RequestCtx {
144        RequestCtx::new(Vec::new(), None, Vec::new(), None, None, 80, false)
145    }
146
147    fn make_ctx_full() -> RequestCtx {
148        RequestCtx::new(
149            b"name=test&value=42".to_vec(),
150            Some("session_id=abc123; theme=dark"),
151            vec![
152                ("Accept".into(), "text/html".into()),
153                ("User-Agent".into(), "bext-test/1.0".into()),
154            ],
155            Some("192.168.1.100"),
156            Some("example.com"),
157            443,
158            true,
159        )
160    }
161
162    #[test]
163    fn new_empty_context() {
164        let ctx = make_ctx();
165        assert!(ctx.output_buf.is_empty());
166        assert!(ctx.request_body.is_empty());
167        assert_eq!(ctx.body_read_pos, 0);
168        assert!(ctx.cookie_header.is_none());
169        assert!(ctx.response_headers.is_empty());
170        assert_eq!(ctx.status_code, 200);
171        assert!(ctx.request_headers.is_empty());
172        assert!(ctx.remote_addr.is_none());
173        assert!(ctx.server_name.is_none());
174        assert_eq!(ctx.server_port, 80);
175        assert!(!ctx.https);
176        assert!(ctx.c_method.is_none());
177        assert!(ctx.c_uri.is_none());
178    }
179
180    #[test]
181    fn new_full_context() {
182        let ctx = make_ctx_full();
183        assert_eq!(ctx.request_body, b"name=test&value=42");
184        assert!(ctx.cookie_header.is_some());
185        assert_eq!(ctx.request_headers.len(), 2);
186        assert_eq!(
187            ctx.remote_addr.as_ref().unwrap().to_str().unwrap(),
188            "192.168.1.100"
189        );
190        assert_eq!(
191            ctx.server_name.as_ref().unwrap().to_str().unwrap(),
192            "example.com"
193        );
194        assert_eq!(ctx.server_port, 443);
195        assert!(ctx.https);
196    }
197
198    #[test]
199    fn set_request_info() {
200        let mut ctx = make_ctx();
201        ctx.set_request_info("POST", "/api/data", "page=1", Some("application/json"));
202        assert_eq!(ctx.c_method.as_ref().unwrap().to_str().unwrap(), "POST");
203        assert_eq!(ctx.c_uri.as_ref().unwrap().to_str().unwrap(), "/api/data");
204        assert_eq!(
205            ctx.c_query_string.as_ref().unwrap().to_str().unwrap(),
206            "page=1"
207        );
208        assert_eq!(
209            ctx.c_content_type.as_ref().unwrap().to_str().unwrap(),
210            "application/json"
211        );
212    }
213
214    #[test]
215    fn reset_for_worker_request() {
216        let mut ctx = make_ctx_full();
217        ctx.output_buf.extend_from_slice(b"old output");
218        ctx.response_headers.push(("X-Old".into(), "val".into()));
219
220        ctx.reset_for_worker_request(WorkerRequestParams {
221            request_body: b"new body".to_vec(),
222            cookie_header: Some("new_cookie=x"),
223            request_headers: vec![("Accept".into(), "application/json".into())],
224            method: "PUT",
225            uri: "/api/update",
226            query_string: "id=42",
227            content_type: Some("application/json"),
228            remote_addr: Some("10.0.0.1"),
229            server_name: Some("api.example.com"),
230            server_port: 8080,
231            https: false,
232        });
233
234        assert!(ctx.output_buf.is_empty());
235        assert_eq!(ctx.request_body, b"new body");
236        assert_eq!(ctx.body_read_pos, 0);
237        assert!(ctx.response_headers.is_empty());
238        assert_eq!(ctx.status_code, 200);
239        assert_eq!(ctx.c_method.as_ref().unwrap().to_str().unwrap(), "PUT");
240        assert_eq!(ctx.server_port, 8080);
241        assert!(!ctx.https);
242    }
243
244    #[test]
245    fn output_buf_accumulates() {
246        let mut ctx = make_ctx();
247        ctx.output_buf.extend_from_slice(b"<html>");
248        ctx.output_buf.extend_from_slice(b"<body>Hello</body>");
249        ctx.output_buf.extend_from_slice(b"</html>");
250        assert_eq!(
251            String::from_utf8_lossy(&ctx.output_buf),
252            "<html><body>Hello</body></html>"
253        );
254    }
255
256    #[test]
257    fn cookie_header_null_bytes_filtered() {
258        let ctx = RequestCtx::new(
259            Vec::new(),
260            Some("bad\0cookie"),
261            Vec::new(),
262            None,
263            None,
264            80,
265            false,
266        );
267        assert!(ctx.cookie_header.is_none());
268    }
269
270    #[test]
271    fn large_body() {
272        let body = vec![0x42u8; 1024 * 1024];
273        let ctx = RequestCtx::new(body, None, Vec::new(), None, None, 80, false);
274        assert_eq!(ctx.request_body.len(), 1024 * 1024);
275    }
276
277    #[test]
278    fn many_headers() {
279        let headers: Vec<(String, String)> = (0..50)
280            .map(|i| (format!("X-Header-{}", i), format!("value-{}", i)))
281            .collect();
282        let ctx = RequestCtx::new(Vec::new(), None, headers, None, None, 80, false);
283        assert_eq!(ctx.request_headers.len(), 50);
284    }
285}