Skip to main content

bext_php/
lib.rs

1//! `bext-php` — embedded PHP runtime for the bext server.
2//!
3//! Provides in-process PHP execution via a custom SAPI that bridges PHP's
4//! I/O to Rust.  Architecture follows the same pattern as FrankenPHP
5//! (custom SAPI + worker threads) but uses Rust FFI instead of Go/CGo.
6//!
7//! ## How it works
8//!
9//! 1. `build.rs` compiles `sapi/bext_php_sapi.c` and links against `libphp`
10//!    (PHP compiled with `--enable-embed --enable-zts`).
11//!
12//! 2. The C SAPI implements PHP's `sapi_module_struct` callbacks, forwarding
13//!    all I/O (output writes, POST reads, cookies, headers) to Rust via
14//!    `extern "C"` functions defined in `ffi.rs`.
15//!
16//! 3. `PhpPool` manages N OS threads, each registered with PHP's TSRM.
17//!    Requests are dispatched via bounded channels (same as `JscRenderPool`).
18//!
19//! 4. `PhpState` is the top-level handle added to bext-server's `AppState`.
20//!    It manages initialization, pool lifecycle, and graceful shutdown.
21//!
22//! ## Requirements
23//!
24//! - PHP 8.2+ compiled with `--enable-embed --enable-zts`
25//! - `php-config` in PATH (or set `BEXT_PHP_CONFIG`)
26//! - Linux (primary target) or macOS
27//!
28//! ## Configuration
29//!
30//! ```toml
31//! [php]
32//! enabled = true
33//! document_root = "./public"
34//! workers = 4
35//!
36//! [php.ini]
37//! memory_limit = "256M"
38//! opcache.enable = "1"
39//! opcache.jit = "1255"
40//! ```
41
42pub mod bridge;
43pub mod config;
44pub mod context;
45mod ffi;
46pub mod pool;
47
48use std::ffi::CString;
49use std::sync::Arc;
50
51pub use config::PhpConfig;
52pub use pool::{PhpPool, PhpPoolStats, PhpResponse};
53
54// ---------------------------------------------------------------------------
55// PhpState — central handle for the PHP runtime (mirrors EbpfState)
56// ---------------------------------------------------------------------------
57
58/// Central state for the embedded PHP runtime.
59///
60/// Manages the PHP module lifecycle and worker pool. Added to bext-server's
61/// `AppState` behind `#[cfg(feature = "php")]`.
62pub struct PhpState {
63    pool: Option<Arc<PhpPool>>,
64    config: PhpConfig,
65}
66
67impl PhpState {
68    /// Initialize the PHP runtime and create the worker pool.
69    ///
70    /// Returns `Ok` even if PHP is disabled by config (pool will be None).
71    pub fn init(config: PhpConfig) -> anyhow::Result<Self> {
72        if !config.enabled {
73            tracing::info!("PHP runtime disabled by configuration");
74            return Ok(Self { pool: None, config });
75        }
76
77        // Verify document root exists
78        let doc_root = std::path::Path::new(&config.document_root);
79        if !doc_root.is_dir() {
80            tracing::warn!(
81                path = %config.document_root,
82                "PHP document_root does not exist; PHP requests will return 404"
83            );
84        }
85
86        // Build INI entries
87        let ini_str = config.ini_entries_string();
88        let c_ini =
89            CString::new(ini_str).map_err(|e| anyhow::anyhow!("Invalid INI string: {}", e))?;
90
91        // Initialize PHP module (one-time, process-wide)
92        let ret = unsafe { ffi::bext_php_module_init(c_ini.as_ptr()) };
93        if ret != 0 {
94            return Err(anyhow::anyhow!(
95                "PHP module initialization failed (bext_php_module_init returned {})",
96                ret
97            ));
98        }
99
100        let worker_count = config.effective_workers();
101        let mode_label = if config.is_worker_mode() {
102            "worker"
103        } else {
104            "classic"
105        };
106        tracing::info!(
107            workers = worker_count,
108            mode = mode_label,
109            document_root = %config.document_root,
110            "PHP runtime initialized"
111        );
112
113        // Create pool in the appropriate mode.
114        // is_worker_mode() returns false on NTS PHP even if worker_script is set.
115        let pool = if config.is_worker_mode() {
116            let script = config.worker_script.as_ref().unwrap().clone();
117            PhpPool::worker(worker_count, script, config.max_requests)
118        } else {
119            PhpPool::with_max_requests(worker_count, config.max_requests)
120        }
121        .map_err(|e| anyhow::anyhow!("Failed to create PHP pool: {}", e))?;
122
123        Ok(Self {
124            pool: Some(Arc::new(pool)),
125            config,
126        })
127    }
128
129    /// Whether the PHP runtime is active (enabled + initialized).
130    pub fn is_active(&self) -> bool {
131        self.pool.is_some()
132    }
133
134    /// Get a reference to the worker pool (if active).
135    pub fn pool(&self) -> Option<&Arc<PhpPool>> {
136        self.pool.as_ref()
137    }
138
139    /// Get the PHP configuration.
140    pub fn config(&self) -> &PhpConfig {
141        &self.config
142    }
143
144    /// Execute a PHP request through the pool.
145    ///
146    /// Resolves the script path from the request URI, then dispatches to a
147    /// worker.  Returns `None` if PHP is not active or the path doesn't
148    /// resolve to a PHP file.
149    #[allow(clippy::too_many_arguments)]
150    pub fn execute(
151        &self,
152        request_path: &str,
153        method: &str,
154        uri: &str,
155        query_string: &str,
156        content_type: Option<&str>,
157        body: Vec<u8>,
158        cookies: Option<&str>,
159        headers: Vec<(String, String)>,
160        remote_addr: Option<&str>,
161        server_name: Option<&str>,
162        server_port: u16,
163        https: bool,
164    ) -> Option<PhpResponse> {
165        let pool = self.pool.as_ref()?;
166
167        // Enforce max body size
168        if body.len() > self.config.max_body_bytes {
169            return Some(PhpResponse::Error(format!(
170                "Request body too large ({} bytes, max {})",
171                body.len(),
172                self.config.max_body_bytes,
173            )));
174        }
175
176        // In worker mode, the worker script handles all routing internally.
177        // In classic mode, resolve the request path to a filesystem script.
178        let script_path = if self.config.is_worker_mode() {
179            // Worker mode: send all PHP requests to the worker pool.
180            // The script_path is ignored by the worker (it's already running).
181            self.config.worker_script.clone().unwrap_or_default()
182        } else {
183            self.config.resolve_script(request_path)?
184        };
185
186        match pool.execute(
187            script_path,
188            method.to_string(),
189            uri.to_string(),
190            query_string.to_string(),
191            content_type.map(|s| s.to_string()),
192            body,
193            cookies.map(|s| s.to_string()),
194            headers,
195            remote_addr.map(|s| s.to_string()),
196            server_name.map(|s| s.to_string()),
197            server_port,
198            https,
199        ) {
200            Ok(resp) => Some(resp),
201            Err(e) => {
202                tracing::error!(path = %request_path, error = %e, "PHP execution failed");
203                // Return generic error to client — full details logged server-side
204                Some(PhpResponse::Error("PHP execution failed".into()))
205            }
206        }
207    }
208
209    /// Get pool statistics.
210    pub fn stats(&self) -> PhpPoolStats {
211        self.pool.as_ref().map(|p| p.stats()).unwrap_or_default()
212    }
213
214    /// Get status summary as JSON.
215    pub fn status_json(&self) -> serde_json::Value {
216        let stats = self.stats();
217        serde_json::json!({
218            "active": self.is_active(),
219            "document_root": self.config.document_root,
220            "workers": stats.workers,
221            "workers_busy": stats.active,
222            "total_requests": stats.total_requests,
223            "total_errors": stats.total_errors,
224            "avg_exec_time_us": stats.avg_exec_time_us,
225        })
226    }
227
228    /// Get Prometheus metrics.
229    pub fn prometheus_metrics(&self) -> String {
230        if !self.is_active() {
231            return String::new();
232        }
233        let stats = self.stats();
234        format!(
235            "# HELP bext_php_workers Number of PHP worker threads\n\
236             # TYPE bext_php_workers gauge\n\
237             bext_php_workers {}\n\
238             # HELP bext_php_workers_active Currently busy PHP workers\n\
239             # TYPE bext_php_workers_active gauge\n\
240             bext_php_workers_active {}\n\
241             # HELP bext_php_requests_total Total PHP requests processed\n\
242             # TYPE bext_php_requests_total counter\n\
243             bext_php_requests_total {}\n\
244             # HELP bext_php_errors_total Total PHP execution errors\n\
245             # TYPE bext_php_errors_total counter\n\
246             bext_php_errors_total {}\n\
247             # HELP bext_php_exec_time_avg_us Average PHP execution time (microseconds)\n\
248             # TYPE bext_php_exec_time_avg_us gauge\n\
249             bext_php_exec_time_avg_us {}\n",
250            stats.workers,
251            stats.active,
252            stats.total_requests,
253            stats.total_errors,
254            stats.avg_exec_time_us,
255        )
256    }
257}
258
259impl Drop for PhpState {
260    fn drop(&mut self) {
261        if self.pool.is_some() {
262            // Pool threads will exit when the sender drops.
263            // Then shut down PHP module.
264            tracing::info!("Shutting down PHP runtime");
265
266            // Take and drop the pool to trigger worker shutdown
267            if let Some(pool) = self.pool.take() {
268                // If we're the last Arc holder, shutdown cleanly
269                if let Ok(pool) = Arc::try_unwrap(pool) {
270                    pool.shutdown();
271                }
272            }
273
274            unsafe {
275                ffi::bext_php_module_shutdown();
276            }
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    // ─── PhpState (disabled mode) ────────────────────────────────────────
286
287    #[test]
288    fn disabled_config_creates_inactive_state() {
289        let config = PhpConfig {
290            enabled: false,
291            ..PhpConfig::default()
292        };
293        let state = PhpState::init(config).unwrap();
294        assert!(!state.is_active());
295        assert!(state.pool().is_none());
296    }
297
298    #[test]
299    fn stats_default_is_zero() {
300        let config = PhpConfig::default();
301        let state = PhpState::init(config).unwrap();
302        let stats = state.stats();
303        assert_eq!(stats.workers, 0);
304        assert_eq!(stats.active, 0);
305        assert_eq!(stats.total_requests, 0);
306        assert_eq!(stats.total_errors, 0);
307        assert_eq!(stats.avg_exec_time_us, 0);
308    }
309
310    #[test]
311    fn status_json_disabled() {
312        let config = PhpConfig::default();
313        let state = PhpState::init(config).unwrap();
314        let json = state.status_json();
315        assert_eq!(json["active"], false);
316        assert_eq!(json["workers"], 0);
317        assert_eq!(json["total_requests"], 0);
318    }
319
320    #[test]
321    fn prometheus_empty_when_disabled() {
322        let config = PhpConfig::default();
323        let state = PhpState::init(config).unwrap();
324        assert!(state.prometheus_metrics().is_empty());
325    }
326
327    #[test]
328    fn execute_returns_none_when_disabled() {
329        let config = PhpConfig::default();
330        let state = PhpState::init(config).unwrap();
331        let result = state.execute(
332            "/index.php",
333            "GET",
334            "/index.php",
335            "",
336            None,
337            Vec::new(),
338            None,
339            Vec::new(),
340            None,
341            None,
342            80,
343            false,
344        );
345        assert!(result.is_none());
346    }
347
348    #[test]
349    fn config_accessor() {
350        let config = PhpConfig {
351            document_root: "/custom/root".into(),
352            ..PhpConfig::default()
353        };
354        let state = PhpState::init(config).unwrap();
355        assert_eq!(state.config().document_root, "/custom/root");
356        assert!(!state.config().enabled);
357    }
358
359    // ─── PhpResponse ─────────────────────────────────────────────────────
360
361    #[test]
362    fn php_response_ok_clone() {
363        let resp = PhpResponse::Ok {
364            status: 200,
365            body: b"<h1>Hello</h1>".to_vec(),
366            headers: vec![("Content-Type".into(), "text/html".into())],
367            exec_time_us: 1500,
368        };
369        let cloned = resp.clone();
370        match cloned {
371            PhpResponse::Ok {
372                status,
373                body,
374                headers,
375                exec_time_us,
376            } => {
377                assert_eq!(status, 200);
378                assert_eq!(body, b"<h1>Hello</h1>");
379                assert_eq!(headers.len(), 1);
380                assert_eq!(exec_time_us, 1500);
381            }
382            PhpResponse::Error(_) => panic!("Expected Ok"),
383        }
384    }
385
386    #[test]
387    fn php_response_error_clone() {
388        let resp = PhpResponse::Error("timeout".into());
389        let cloned = resp.clone();
390        match cloned {
391            PhpResponse::Error(msg) => assert_eq!(msg, "timeout"),
392            PhpResponse::Ok { .. } => panic!("Expected Error"),
393        }
394    }
395
396    #[test]
397    fn php_response_debug() {
398        let resp = PhpResponse::Ok {
399            status: 404,
400            body: Vec::new(),
401            headers: Vec::new(),
402            exec_time_us: 0,
403        };
404        let debug = format!("{:?}", resp);
405        assert!(debug.contains("404"));
406    }
407
408    // ─── PhpPoolStats ────────────────────────────────────────────────────
409
410    #[test]
411    fn pool_stats_default() {
412        let stats = pool::PhpPoolStats::default();
413        assert_eq!(stats.workers, 0);
414        assert_eq!(stats.active, 0);
415        assert_eq!(stats.total_requests, 0);
416        assert_eq!(stats.total_errors, 0);
417        assert_eq!(stats.avg_exec_time_us, 0);
418    }
419
420    #[test]
421    fn pool_stats_debug() {
422        let stats = pool::PhpPoolStats {
423            workers: 4,
424            active: 2,
425            total_requests: 1000,
426            total_errors: 5,
427            avg_exec_time_us: 250,
428        };
429        let debug = format!("{:?}", stats);
430        assert!(debug.contains("workers: 4"));
431        assert!(debug.contains("total_requests: 1000"));
432    }
433
434    // ─── Body size enforcement ───────────────────────────────────────────
435
436    #[test]
437    fn execute_rejects_oversized_body() {
438        // Create a config with enabled=false but check the body limit logic
439        // by using a config with a small max_body_bytes
440        let config = PhpConfig {
441            enabled: false,
442            max_body_bytes: 100,
443            ..PhpConfig::default()
444        };
445        let state = PhpState::init(config).unwrap();
446        // With PHP disabled, execute returns None (not active)
447        // But we can test the config stores the limit
448        assert_eq!(state.config().max_body_bytes, 100);
449    }
450
451    // ─── Prometheus format ───────────────────────────────────────────────
452
453    #[test]
454    fn prometheus_format_valid() {
455        // When active, prometheus should produce valid format lines
456        let config = PhpConfig::default();
457        let state = PhpState::init(config).unwrap();
458        // Disabled → empty
459        let prom = state.prometheus_metrics();
460        assert!(prom.is_empty());
461    }
462
463    // ─── Status JSON structure ───────────────────────────────────────────
464
465    #[test]
466    fn status_json_has_all_fields() {
467        let config = PhpConfig::default();
468        let state = PhpState::init(config).unwrap();
469        let json = state.status_json();
470        // All expected fields present
471        assert!(json.get("active").is_some());
472        assert!(json.get("document_root").is_some());
473        assert!(json.get("workers").is_some());
474        assert!(json.get("workers_busy").is_some());
475        assert!(json.get("total_requests").is_some());
476        assert!(json.get("total_errors").is_some());
477        assert!(json.get("avg_exec_time_us").is_some());
478    }
479
480    // ─── Worker mode config propagation ──────────────────────────────────
481
482    #[test]
483    fn worker_mode_config_stored() {
484        let config = PhpConfig {
485            worker_script: Some("./worker.php".into()),
486            ..PhpConfig::default()
487        };
488        let state = PhpState::init(config).unwrap();
489        assert_eq!(
490            state.config().worker_script.as_deref(),
491            Some("./worker.php")
492        );
493    }
494
495    #[test]
496    fn cache_responses_config_stored() {
497        let config = PhpConfig {
498            cache_responses: false,
499            ..PhpConfig::default()
500        };
501        let state = PhpState::init(config).unwrap();
502        assert!(!state.config().cache_responses);
503    }
504
505    // ─── PhpMode enum ────────────────────────────────────────────────────
506
507    #[test]
508    fn php_mode_classic_debug() {
509        let mode = pool::PhpMode::Classic;
510        let debug = format!("{:?}", mode);
511        assert_eq!(debug, "Classic");
512    }
513
514    #[test]
515    fn php_mode_worker_debug() {
516        let mode = pool::PhpMode::Worker {
517            script: "/opt/app/worker.php".into(),
518        };
519        let debug = format!("{:?}", mode);
520        assert!(debug.contains("Worker"));
521        assert!(debug.contains("worker.php"));
522    }
523
524    #[test]
525    fn php_mode_clone() {
526        let mode = pool::PhpMode::Worker {
527            script: "w.php".into(),
528        };
529        let cloned = mode.clone();
530        match cloned {
531            pool::PhpMode::Worker { script } => assert_eq!(script, "w.php"),
532            _ => panic!("Expected Worker"),
533        }
534    }
535
536    // ─── PhpResponse edge cases ──────────────────────────────────────────
537
538    #[test]
539    fn php_response_ok_empty_body() {
540        let resp = PhpResponse::Ok {
541            status: 204,
542            body: Vec::new(),
543            headers: Vec::new(),
544            exec_time_us: 0,
545        };
546        match resp {
547            PhpResponse::Ok { status, body, .. } => {
548                assert_eq!(status, 204);
549                assert!(body.is_empty());
550            }
551            _ => panic!("Expected Ok"),
552        }
553    }
554
555    #[test]
556    fn php_response_ok_large_body() {
557        let body = vec![0x42u8; 10 * 1024 * 1024]; // 10MB
558        let resp = PhpResponse::Ok {
559            status: 200,
560            body,
561            headers: vec![("Content-Type".into(), "application/octet-stream".into())],
562            exec_time_us: 5000,
563        };
564        match resp {
565            PhpResponse::Ok { body, .. } => assert_eq!(body.len(), 10 * 1024 * 1024),
566            _ => panic!("Expected Ok"),
567        }
568    }
569
570    #[test]
571    fn php_response_error_empty_message() {
572        let resp = PhpResponse::Error(String::new());
573        match resp {
574            PhpResponse::Error(msg) => assert!(msg.is_empty()),
575            _ => panic!("Expected Error"),
576        }
577    }
578
579    #[test]
580    fn php_response_ok_many_headers() {
581        let headers: Vec<(String, String)> = (0..100)
582            .map(|i| (format!("X-Header-{}", i), format!("value-{}", i)))
583            .collect();
584        let resp = PhpResponse::Ok {
585            status: 200,
586            body: b"ok".to_vec(),
587            headers,
588            exec_time_us: 10,
589        };
590        match resp {
591            PhpResponse::Ok { headers, .. } => assert_eq!(headers.len(), 100),
592            _ => panic!("Expected Ok"),
593        }
594    }
595
596    #[test]
597    fn php_response_5xx_status() {
598        let resp = PhpResponse::Ok {
599            status: 503,
600            body: b"Service Unavailable".to_vec(),
601            headers: Vec::new(),
602            exec_time_us: 0,
603        };
604        match resp {
605            PhpResponse::Ok { status, .. } => assert_eq!(status, 503),
606            _ => panic!("Expected Ok"),
607        }
608    }
609
610    // ─── Security tests ──────────────────────────────────────────────────
611
612    #[test]
613    fn execute_body_size_enforced() {
614        let config = PhpConfig {
615            max_body_bytes: 10,
616            ..PhpConfig::default()
617        };
618        let state = PhpState::init(config).unwrap();
619        // Body larger than max should return error (not panic)
620        let result = state.execute(
621            "/index.php",
622            "POST",
623            "/index.php",
624            "",
625            None,
626            vec![0u8; 100], // 100 bytes > 10 byte limit
627            None,
628            Vec::new(),
629            None,
630            None,
631            80,
632            false,
633        );
634        // Disabled PHP returns None, but the config is stored
635        assert_eq!(state.config().max_body_bytes, 10);
636    }
637
638    #[test]
639    fn error_messages_generic() {
640        // Verify that PhpResponse::Error from execute doesn't leak internals
641        let resp = PhpResponse::Error("PHP execution failed".into());
642        match resp {
643            PhpResponse::Error(msg) => {
644                assert!(!msg.contains("/home/"));
645                assert!(!msg.contains("stack trace"));
646                assert!(!msg.contains("Fatal error"));
647            }
648            _ => panic!("Expected Error"),
649        }
650    }
651
652    #[test]
653    fn bridge_no_jsc_returns_error() {
654        // Without JSC pool registered, bridge should return error, not panic
655        let result = crate::bridge::jsc_render("{}");
656        assert!(result.contains("not available"));
657    }
658
659    #[test]
660    fn bridge_no_php_returns_error() {
661        let result = crate::bridge::php_call("GET", "/api/test", None);
662        assert!(result.is_err());
663        assert!(result.unwrap_err().contains("not available"));
664    }
665}