Skip to main content

bext_php/
config.rs

1//! PHP runtime configuration.
2//!
3//! Parsed from the `[php]` section of `bext.config.toml`.
4
5use serde::Deserialize;
6
7/// Configuration for the embedded PHP runtime.
8#[derive(Debug, Deserialize)]
9pub struct PhpConfig {
10    /// Enable the PHP runtime. Default: false.
11    #[serde(default)]
12    pub enabled: bool,
13
14    /// Document root for PHP scripts. Requests are resolved relative to this.
15    /// Default: "./public"
16    #[serde(default = "default_document_root")]
17    pub document_root: String,
18
19    /// Number of PHP worker threads. Default: number of CPU cores.
20    /// Each thread holds a PHP TSRM context and processes one request at a time.
21    pub workers: Option<usize>,
22
23    /// PHP INI overrides (key=value pairs).
24    ///
25    /// Example:
26    /// ```toml
27    /// [php.ini]
28    /// display_errors = "Off"
29    /// memory_limit = "256M"
30    /// opcache.enable = "1"
31    /// ```
32    #[serde(default)]
33    pub ini: std::collections::HashMap<String, String>,
34
35    /// File extensions to treat as PHP scripts. Default: [".php"]
36    #[serde(default = "default_extensions")]
37    pub extensions: Vec<String>,
38
39    /// Index file name for directory requests. Default: "index.php"
40    #[serde(default = "default_index")]
41    pub index: String,
42
43    /// Maximum request body size in bytes. Default: 8MB.
44    #[serde(default = "default_max_body")]
45    pub max_body_bytes: usize,
46
47    /// Maximum execution time per request in seconds. Default: 30.
48    #[serde(default = "default_max_execution_time")]
49    pub max_execution_time: u32,
50
51    /// Enable ISR caching for PHP responses. Default: true.
52    /// GET requests with 200 status are cached using the ISR cache with
53    /// tag-based invalidation. POST/PUT/DELETE requests bypass the cache.
54    /// Use route_rules to control TTL per path pattern.
55    #[serde(default = "default_true_val")]
56    pub cache_responses: bool,
57
58    /// Worker lifecycle: rotate after this many requests. Default: 10000.
59    /// Helps contain memory leaks in PHP extensions.
60    #[serde(default = "default_max_requests")]
61    pub max_requests: u64,
62
63    /// Worker mode: path to the worker PHP script.
64    /// When set, bext-php boots this script once per thread and dispatches
65    /// requests to its `bext_handle_request($callback)` loop.
66    /// Eliminates per-request framework bootstrap (~3ms for Laravel).
67    ///
68    /// Example worker script:
69    /// ```php
70    /// $app = require __DIR__.'/../bootstrap/app.php';
71    /// $kernel = $app->make(\Illuminate\Contracts\Http\Kernel::class);
72    /// while (bext_handle_request(function() use ($kernel) {
73    ///     $response = $kernel->handle($request = \Illuminate\Http\Request::capture());
74    ///     $response->send();
75    ///     $kernel->terminate($request, $response);
76    /// })) { gc_collect_cycles(); }
77    /// ```
78    pub worker_script: Option<String>,
79}
80
81impl Default for PhpConfig {
82    fn default() -> Self {
83        Self {
84            enabled: false,
85            document_root: default_document_root(),
86            workers: None,
87            ini: std::collections::HashMap::new(),
88            extensions: default_extensions(),
89            index: default_index(),
90            max_body_bytes: default_max_body(),
91            max_execution_time: default_max_execution_time(),
92            max_requests: default_max_requests(),
93            worker_script: None,
94            cache_responses: true,
95        }
96    }
97}
98
99fn default_true_val() -> bool {
100    true
101}
102
103impl PhpConfig {
104    /// Effective worker count (configured or CPU count).
105    ///
106    /// NTS (non-thread-safe) PHP is limited to 1 worker because the PHP
107    /// interpreter uses global state without TSRM protection.  ZTS builds
108    /// can use multiple workers safely.
109    pub fn effective_workers(&self) -> usize {
110        let requested = self.workers.unwrap_or_else(|| {
111            std::thread::available_parallelism()
112                .map(|n| n.get())
113                .unwrap_or(4)
114        });
115
116        #[cfg(not(php_zts))]
117        {
118            if requested > 1 {
119                tracing::warn!(
120                    requested = requested,
121                    "PHP is NTS (non-thread-safe) — clamping to 1 worker. \
122                     Rebuild PHP with --enable-zts for multi-threaded mode."
123                );
124            }
125            #[allow(clippy::needless_return)]
126            return 1;
127        }
128
129        #[cfg(php_zts)]
130        requested
131    }
132
133    /// Build the INI entries string for `bext_php_module_init`.
134    /// Applies sensible production defaults for OPcache/JIT when not
135    /// explicitly overridden by the user.
136    pub fn ini_entries_string(&self) -> String {
137        let mut entries = String::new();
138
139        // Production defaults — overridden by explicit [php.ini] entries
140        // Note: JIT is intentionally NOT enabled by default — PHP 8.4's JIT
141        // has stack size issues on some workloads.  Users can opt-in via:
142        //   [php.ini]
143        //   "opcache.jit" = "1255"
144        //   "opcache.jit_buffer_size" = "64M"
145        let defaults = [
146            ("max_execution_time", self.max_execution_time.to_string()),
147            // Disable stack size check — PHP 8.4's default limit (8MB) is too
148            // small for the embedded SAPI's worker threads.  -1 = unlimited.
149            ("zend.max_allowed_stack_size", "-1".into()),
150            ("opcache.enable", "1".into()),
151            ("opcache.enable_cli", "1".into()),
152            ("opcache.validate_timestamps", "0".into()),
153            ("opcache.memory_consumption", "128".into()),
154            ("opcache.max_accelerated_files", "10000".into()),
155            ("opcache.interned_strings_buffer", "16".into()),
156            ("realpath_cache_size", "4096K".into()),
157            ("realpath_cache_ttl", "600".into()),
158            // ── Security hardening ──
159            // Restrict filesystem access to the document root + /tmp.
160            // Users can widen this via [php.ini] if needed.
161            ("open_basedir", format!("{}:/tmp", self.document_root)),
162            // Disable dynamic extension loading — prevents dl('malicious.so').
163            ("enable_dl", "0".into()),
164            // Disable dangerous functions that allow command execution or
165            // process control. Users can override via [php.ini] if they
166            // explicitly need these (e.g., for CLI tooling).
167            ("disable_functions", [
168                "exec", "system", "passthru", "shell_exec",
169                "proc_open", "popen", "proc_close", "proc_terminate",
170                "proc_get_status", "proc_nice",
171                "pcntl_exec", "pcntl_fork", "pcntl_signal", "pcntl_waitpid",
172                "pcntl_wexitstatus", "pcntl_alarm",
173                "dl",
174                "putenv",       // can manipulate LD_PRELOAD → RCE
175                "apache_setenv",
176                "show_source", "phpinfo",
177            ].join(",").into()),
178            // Prevent information disclosure.
179            ("expose_php", "Off".into()),
180            ("display_errors", "Off".into()),
181            ("log_errors", "On".into()),
182        ];
183
184        for (key, value) in &defaults {
185            if !self.ini.contains_key(*key) {
186                if key.contains('\n')
187                    || key.contains('\r')
188                    || key.contains('\0')
189                    || value.contains('\n')
190                    || value.contains('\r')
191                    || value.contains('\0')
192                {
193                    eprintln!(
194                        "[SECURITY] Skipping INI entry with unsafe characters: {}",
195                        key
196                    );
197                    continue;
198                }
199                entries.push_str(&format!("{}={}\n", key, value));
200            }
201        }
202
203        // Security-critical INI keys that weaken the sandbox when overridden.
204        // Warn loudly so operators notice if their config removes protections.
205        const SECURITY_CRITICAL_KEYS: &[&str] = &[
206            "disable_functions",
207            "open_basedir",
208            "enable_dl",
209            "allow_url_include",
210            "allow_url_fopen",
211        ];
212
213        // Apply user overrides (these take precedence)
214        for (key, value) in &self.ini {
215            if key.contains('\n')
216                || key.contains('\r')
217                || key.contains('\0')
218                || value.contains('\n')
219                || value.contains('\r')
220                || value.contains('\0')
221            {
222                eprintln!(
223                    "[SECURITY] Skipping INI entry with unsafe characters: {}",
224                    key
225                );
226                continue;
227            }
228            if SECURITY_CRITICAL_KEYS.iter().any(|&k| k == key.as_str()) {
229                eprintln!(
230                    "[SECURITY WARNING] PHP INI override for '{}' — this weakens the \
231                     default sandbox. Ensure this is intentional. Value: '{}'",
232                    key, value
233                );
234                tracing::warn!(
235                    key = key.as_str(),
236                    value = value.as_str(),
237                    "PHP security-critical INI override — default sandbox weakened"
238                );
239            }
240            entries.push_str(&format!("{}={}\n", key, value));
241        }
242
243        entries
244    }
245
246    /// Whether worker mode is configured and available.
247    /// Worker mode requires ZTS PHP — NTS PHP can only run classic mode
248    /// because the PHP interpreter uses process-global state that isn't
249    /// safe to access from spawned threads in worker mode.
250    pub fn is_worker_mode(&self) -> bool {
251        if self.worker_script.is_none() {
252            return false;
253        }
254
255        #[cfg(php_zts)]
256        {
257            true
258        }
259
260        #[cfg(not(php_zts))]
261        {
262            tracing::warn!(
263                "Worker mode requires ZTS PHP (--enable-zts). \
264                 Falling back to classic mode."
265            );
266            false
267        }
268    }
269
270    /// Check whether a request path should be handled by PHP.
271    pub fn is_php_request(&self, path: &str) -> bool {
272        // Direct match on extension
273        for ext in &self.extensions {
274            if path.ends_with(ext.as_str()) {
275                return true;
276            }
277        }
278        // Directory request → try index file
279        if path.ends_with('/') || !path.contains('.') {
280            return true; // Will resolve to index.php
281        }
282        false
283    }
284
285    /// Resolve a request path to a filesystem path.
286    ///
287    /// Returns the absolute script path if the file exists, or None.
288    pub fn resolve_script(&self, request_path: &str) -> Option<String> {
289        let doc_root = std::path::Path::new(&self.document_root);
290
291        // Strip leading slash and reject path traversal patterns
292        let relative = request_path.trim_start_matches('/');
293        if relative.contains("..") || relative.contains('\0') {
294            return None;
295        }
296
297        // Direct file match — then verify it's inside document_root via canonicalize
298        // to prevent symlink-based traversal attacks.
299        let candidate = doc_root.join(relative);
300        if candidate.is_file() {
301            // Canonicalize both paths to resolve symlinks
302            let canon_root = std::fs::canonicalize(doc_root).ok()?;
303            let canon_file = std::fs::canonicalize(&candidate).ok()?;
304            if !canon_file.starts_with(&canon_root) {
305                tracing::warn!(
306                    path = %request_path,
307                    resolved = %canon_file.display(),
308                    root = %canon_root.display(),
309                    "Path traversal blocked (symlink escape)"
310                );
311                return None;
312            }
313            return canon_file.to_str().map(|s| s.to_string());
314        }
315
316        // Try appending index file for directory-like paths
317        let index_candidate = doc_root.join(relative).join(&self.index);
318
319        if index_candidate.is_file() {
320            return index_candidate.to_str().map(|s| s.to_string());
321        }
322
323        // Try router script (front controller pattern: all requests → index.php)
324        let router = doc_root.join(&self.index);
325        if router.is_file() {
326            return router.to_str().map(|s| s.to_string());
327        }
328
329        None
330    }
331}
332
333fn default_document_root() -> String {
334    "./public".into()
335}
336
337fn default_extensions() -> Vec<String> {
338    vec![".php".into()]
339}
340
341fn default_index() -> String {
342    "index.php".into()
343}
344
345fn default_max_body() -> usize {
346    8 * 1024 * 1024
347}
348
349fn default_max_execution_time() -> u32 {
350    30
351}
352
353fn default_max_requests() -> u64 {
354    10_000
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    // ─── Default values ──────────────────────────────────────────────────
362
363    #[test]
364    fn default_config() {
365        let config = PhpConfig::default();
366        assert!(!config.enabled);
367        assert_eq!(config.document_root, "./public");
368        assert_eq!(config.extensions, vec![".php"]);
369        assert_eq!(config.index, "index.php");
370        assert_eq!(config.max_body_bytes, 8 * 1024 * 1024);
371        assert_eq!(config.max_execution_time, 30);
372        assert_eq!(config.max_requests, 10_000);
373        assert!(config.workers.is_none());
374        assert!(config.ini.is_empty());
375    }
376
377    // ─── INI generation ──────────────────────────────────────────────────
378
379    #[test]
380    fn ini_entries_string() {
381        let mut config = PhpConfig::default();
382        config.ini.insert("memory_limit".into(), "256M".into());
383        config.ini.insert("display_errors".into(), "Off".into());
384        let ini = config.ini_entries_string();
385        assert!(ini.contains("max_execution_time=30"));
386        assert!(ini.contains("memory_limit=256M"));
387        assert!(ini.contains("display_errors=Off"));
388    }
389
390    #[test]
391    fn ini_entries_includes_defaults_when_no_overrides() {
392        let config = PhpConfig::default();
393        let ini = config.ini_entries_string();
394        assert!(ini.contains("max_execution_time=30"));
395        assert!(ini.contains("opcache.enable=1"));
396        assert!(ini.contains("opcache.validate_timestamps=0"));
397        assert!(ini.contains("realpath_cache_size=4096K"));
398        // JIT is NOT enabled by default (stack size issues in PHP 8.4)
399        assert!(!ini.contains("opcache.jit="));
400    }
401
402    #[test]
403    fn ini_entries_custom_execution_time() {
404        let mut config = PhpConfig::default();
405        config.max_execution_time = 120;
406        let ini = config.ini_entries_string();
407        assert!(ini.starts_with("max_execution_time=120\n"));
408    }
409
410    // ─── Request detection ───────────────────────────────────────────────
411
412    #[test]
413    fn is_php_request_extensions() {
414        let config = PhpConfig::default();
415        assert!(config.is_php_request("/index.php"));
416        assert!(config.is_php_request("/api/users.php"));
417        assert!(config.is_php_request("/")); // directory → index.php
418        assert!(config.is_php_request("/api/users")); // no extension → try PHP
419        assert!(!config.is_php_request("/style.css"));
420        assert!(!config.is_php_request("/script.js"));
421        assert!(!config.is_php_request("/image.png"));
422    }
423
424    #[test]
425    fn is_php_request_custom_extensions() {
426        let mut config = PhpConfig::default();
427        config.extensions = vec![".php".into(), ".phtml".into(), ".php7".into()];
428        assert!(config.is_php_request("/template.phtml"));
429        assert!(config.is_php_request("/legacy.php7"));
430        assert!(config.is_php_request("/index.php"));
431        assert!(!config.is_php_request("/style.css"));
432    }
433
434    #[test]
435    fn is_php_request_trailing_slash() {
436        let config = PhpConfig::default();
437        assert!(config.is_php_request("/admin/"));
438        assert!(config.is_php_request("/"));
439        assert!(config.is_php_request("/api/v2/"));
440    }
441
442    #[test]
443    fn is_php_request_no_extension_paths() {
444        let config = PhpConfig::default();
445        // Paths without extensions are treated as PHP (front controller)
446        assert!(config.is_php_request("/users"));
447        assert!(config.is_php_request("/api/v2/products"));
448        assert!(config.is_php_request("/dashboard"));
449    }
450
451    #[test]
452    fn is_php_request_static_files_rejected() {
453        let config = PhpConfig::default();
454        assert!(!config.is_php_request("/favicon.ico"));
455        assert!(!config.is_php_request("/robots.txt"));
456        assert!(!config.is_php_request("/sitemap.xml"));
457        assert!(!config.is_php_request("/assets/app.js"));
458        assert!(!config.is_php_request("/css/main.css"));
459        assert!(!config.is_php_request("/images/logo.png"));
460        assert!(!config.is_php_request("/fonts/inter.woff2"));
461    }
462
463    // ─── Script resolution ───────────────────────────────────────────────
464
465    #[test]
466    fn resolve_script_direct_file() {
467        let tmp = std::env::temp_dir().join("bext-php-test-resolve");
468        let _ = std::fs::remove_dir_all(&tmp);
469        std::fs::create_dir_all(&tmp).unwrap();
470        std::fs::write(tmp.join("info.php"), "<?php phpinfo();").unwrap();
471
472        let mut config = PhpConfig::default();
473        config.document_root = tmp.to_str().unwrap().to_string();
474
475        let resolved = config.resolve_script("/info.php");
476        assert!(resolved.is_some());
477        assert!(resolved.unwrap().ends_with("info.php"));
478
479        let _ = std::fs::remove_dir_all(&tmp);
480    }
481
482    #[test]
483    fn resolve_script_index_file() {
484        let tmp = std::env::temp_dir().join("bext-php-test-index");
485        let _ = std::fs::remove_dir_all(&tmp);
486        std::fs::create_dir_all(&tmp).unwrap();
487        std::fs::write(tmp.join("index.php"), "<?php echo 'home';").unwrap();
488
489        let mut config = PhpConfig::default();
490        config.document_root = tmp.to_str().unwrap().to_string();
491
492        // Root path should resolve to index.php
493        let resolved = config.resolve_script("/");
494        assert!(resolved.is_some());
495        assert!(resolved.unwrap().ends_with("index.php"));
496
497        let _ = std::fs::remove_dir_all(&tmp);
498    }
499
500    #[test]
501    fn resolve_script_front_controller() {
502        let tmp = std::env::temp_dir().join("bext-php-test-router");
503        let _ = std::fs::remove_dir_all(&tmp);
504        std::fs::create_dir_all(&tmp).unwrap();
505        std::fs::write(tmp.join("index.php"), "<?php /* router */").unwrap();
506
507        let mut config = PhpConfig::default();
508        config.document_root = tmp.to_str().unwrap().to_string();
509
510        // Non-existent path should fall back to index.php (front controller)
511        let resolved = config.resolve_script("/api/users/42");
512        assert!(resolved.is_some());
513        assert!(resolved.unwrap().ends_with("index.php"));
514
515        let _ = std::fs::remove_dir_all(&tmp);
516    }
517
518    #[test]
519    fn resolve_script_subdirectory() {
520        let tmp = std::env::temp_dir().join("bext-php-test-subdir");
521        let _ = std::fs::remove_dir_all(&tmp);
522        std::fs::create_dir_all(tmp.join("admin")).unwrap();
523        std::fs::write(tmp.join("admin/dashboard.php"), "<?php").unwrap();
524
525        let mut config = PhpConfig::default();
526        config.document_root = tmp.to_str().unwrap().to_string();
527
528        let resolved = config.resolve_script("/admin/dashboard.php");
529        assert!(resolved.is_some());
530        assert!(resolved.unwrap().contains("admin/dashboard.php"));
531
532        let _ = std::fs::remove_dir_all(&tmp);
533    }
534
535    #[test]
536    fn resolve_script_missing_doc_root() {
537        let config = PhpConfig {
538            document_root: "/nonexistent/path/xyz".into(),
539            ..PhpConfig::default()
540        };
541        assert!(config.resolve_script("/index.php").is_none());
542    }
543
544    #[test]
545    fn resolve_script_path_traversal_blocked() {
546        let config = PhpConfig::default();
547        assert!(config.resolve_script("/../../../etc/passwd").is_none());
548        assert!(config
549            .resolve_script("/admin/../../../etc/shadow")
550            .is_none());
551        assert!(config.resolve_script("/..").is_none());
552    }
553
554    #[test]
555    fn resolve_script_custom_index() {
556        let tmp = std::env::temp_dir().join("bext-php-test-custom-idx");
557        let _ = std::fs::remove_dir_all(&tmp);
558        std::fs::create_dir_all(&tmp).unwrap();
559        std::fs::write(tmp.join("app.php"), "<?php").unwrap();
560
561        let config = PhpConfig {
562            document_root: tmp.to_str().unwrap().to_string(),
563            index: "app.php".into(),
564            ..PhpConfig::default()
565        };
566
567        let resolved = config.resolve_script("/");
568        assert!(resolved.is_some());
569        assert!(resolved.unwrap().ends_with("app.php"));
570
571        let _ = std::fs::remove_dir_all(&tmp);
572    }
573
574    // ─── Workers ─────────────────────────────────────────────────────────
575
576    #[test]
577    fn effective_workers_default() {
578        let config = PhpConfig::default();
579        assert!(config.effective_workers() > 0);
580    }
581
582    #[test]
583    fn effective_workers_override() {
584        let mut config = PhpConfig::default();
585        config.workers = Some(8);
586        #[cfg(php_zts)]
587        assert_eq!(config.effective_workers(), 8);
588        #[cfg(not(php_zts))]
589        assert_eq!(config.effective_workers(), 1);
590    }
591
592    // ─── TOML deserialization ────────────────────────────────────────────
593
594    #[test]
595    fn toml_round_trip() {
596        let toml_str = r#"
597            enabled = true
598            document_root = "/var/www/html"
599            workers = 4
600            index = "app.php"
601            max_execution_time = 60
602            max_requests = 5000
603            extensions = [".php", ".phtml"]
604
605            [ini]
606            memory_limit = "512M"
607            "opcache.enable" = "1"
608            "opcache.jit" = "1255"
609        "#;
610        let config: PhpConfig = toml::from_str(toml_str).unwrap();
611        assert!(config.enabled);
612        assert_eq!(config.document_root, "/var/www/html");
613        assert_eq!(config.workers, Some(4));
614        assert_eq!(config.index, "app.php");
615        assert_eq!(config.max_execution_time, 60);
616        assert_eq!(config.max_requests, 5000);
617        assert_eq!(config.ini.get("memory_limit").unwrap(), "512M");
618        assert_eq!(config.ini.get("opcache.enable").unwrap(), "1");
619        assert_eq!(config.extensions, vec![".php", ".phtml"]);
620    }
621
622    #[test]
623    fn toml_minimal() {
624        let toml_str = r#"enabled = true"#;
625        let config: PhpConfig = toml::from_str(toml_str).unwrap();
626        assert!(config.enabled);
627        assert_eq!(config.document_root, "./public");
628        assert_eq!(config.extensions, vec![".php"]);
629    }
630
631    #[test]
632    fn toml_empty() {
633        let config: PhpConfig = toml::from_str("").unwrap();
634        assert!(!config.enabled);
635    }
636
637    #[test]
638    fn toml_max_body_override() {
639        let toml_str = r#"max_body_bytes = 1048576"#;
640        let config: PhpConfig = toml::from_str(toml_str).unwrap();
641        assert_eq!(config.max_body_bytes, 1_048_576); // 1MB
642    }
643
644    #[test]
645    fn toml_nested_in_server_config() {
646        let toml_str = r#"
647            [php]
648            enabled = true
649            document_root = "/srv/app/public"
650            workers = 2
651
652            [php.ini]
653            "session.save_handler" = "files"
654        "#;
655
656        #[derive(serde::Deserialize)]
657        struct Wrapper {
658            php: PhpConfig,
659        }
660
661        let wrapper: Wrapper = toml::from_str(toml_str).unwrap();
662        assert!(wrapper.php.enabled);
663        assert_eq!(wrapper.php.document_root, "/srv/app/public");
664        assert_eq!(wrapper.php.workers, Some(2));
665        assert_eq!(
666            wrapper.php.ini.get("session.save_handler").unwrap(),
667            "files"
668        );
669    }
670
671    // ─── Worker mode detection ───────────────────────────────────────────
672
673    #[test]
674    fn is_worker_mode_without_script() {
675        let config = PhpConfig::default();
676        assert!(!config.is_worker_mode());
677    }
678
679    #[test]
680    fn is_worker_mode_with_script() {
681        let config = PhpConfig {
682            worker_script: Some("./worker.php".into()),
683            ..PhpConfig::default()
684        };
685        // ZTS: worker mode enabled. NTS: falls back to classic.
686        #[cfg(php_zts)]
687        assert!(config.is_worker_mode());
688        #[cfg(not(php_zts))]
689        assert!(!config.is_worker_mode());
690    }
691
692    // ─── cache_responses field ───────────────────────────────────────────
693
694    #[test]
695    fn cache_responses_default_true() {
696        let config = PhpConfig::default();
697        assert!(config.cache_responses);
698    }
699
700    #[test]
701    fn cache_responses_disable() {
702        let toml_str = r#"cache_responses = false"#;
703        let config: PhpConfig = toml::from_str(toml_str).unwrap();
704        assert!(!config.cache_responses);
705    }
706
707    // ─── TOML with worker_script ─────────────────────────────────────────
708
709    #[test]
710    fn toml_worker_script() {
711        let toml_str = r#"
712            enabled = true
713            worker_script = "./bootstrap/worker.php"
714            workers = 4
715        "#;
716        let config: PhpConfig = toml::from_str(toml_str).unwrap();
717        assert_eq!(
718            config.worker_script.as_deref(),
719            Some("./bootstrap/worker.php")
720        );
721        assert_eq!(config.workers, Some(4));
722    }
723
724    #[test]
725    fn toml_full_with_all_fields() {
726        let toml_str = r#"
727            enabled = true
728            document_root = "/var/www"
729            workers = 8
730            max_execution_time = 120
731            max_requests = 50000
732            max_body_bytes = 16777216
733            cache_responses = false
734            worker_script = "/opt/app/worker.php"
735            extensions = [".php", ".phtml"]
736            index = "app.php"
737
738            [ini]
739            memory_limit = "1G"
740        "#;
741        let config: PhpConfig = toml::from_str(toml_str).unwrap();
742        assert!(config.enabled);
743        assert_eq!(config.document_root, "/var/www");
744        assert_eq!(config.workers, Some(8));
745        assert_eq!(config.max_execution_time, 120);
746        assert_eq!(config.max_requests, 50000);
747        assert_eq!(config.max_body_bytes, 16_777_216);
748        assert!(!config.cache_responses);
749        assert_eq!(config.worker_script.as_deref(), Some("/opt/app/worker.php"));
750        assert_eq!(config.extensions, vec![".php", ".phtml"]);
751        assert_eq!(config.index, "app.php");
752        assert_eq!(config.ini.get("memory_limit").unwrap(), "1G");
753    }
754
755    // ─── INI edge cases ──────────────────────────────────────────────────
756
757    #[test]
758    fn ini_user_overrides_default() {
759        let mut config = PhpConfig::default();
760        // User sets opcache.enable=0 which should override the default of 1
761        config.ini.insert("opcache.enable".into(), "0".into());
762        let ini = config.ini_entries_string();
763        // The user's value should appear (defaults don't re-add already-set keys)
764        assert!(ini.contains("opcache.enable=0"));
765        // Should NOT have opcache.enable=1 from defaults
766        let count = ini.matches("opcache.enable=").count();
767        assert_eq!(count, 1);
768    }
769
770    // ─── is_php_request edge cases ───────────────────────────────────────
771
772    #[test]
773    fn is_php_request_empty_path() {
774        let config = PhpConfig::default();
775        // Empty string has no extension and no slash → treated as PHP (front controller)
776        assert!(config.is_php_request(""));
777    }
778
779    #[test]
780    fn is_php_request_double_extension() {
781        let config = PhpConfig::default();
782        assert!(config.is_php_request("/file.backup.php"));
783        assert!(!config.is_php_request("/file.php.bak"));
784    }
785
786    #[test]
787    fn is_php_request_with_query_component() {
788        let config = PhpConfig::default();
789        // In practice, the handler passes only the path (no query string).
790        // But if a query is included, .php extension should still match.
791        assert!(config.is_php_request("/index.php"));
792        // Path with query: the "?" introduces a "." which causes the
793        // no-extension check to fail — callers should strip query first.
794        // This documents the current behavior:
795        assert!(!config.is_php_request("/index.php?foo=bar"));
796    }
797
798    // ─── resolve_script edge cases ───────────────────────────────────────
799
800    #[test]
801    fn resolve_script_empty_path() {
802        let tmp = std::env::temp_dir().join("bext-php-test-empty");
803        let _ = std::fs::remove_dir_all(&tmp);
804        std::fs::create_dir_all(&tmp).unwrap();
805        std::fs::write(tmp.join("index.php"), "<?php").unwrap();
806
807        let config = PhpConfig {
808            document_root: tmp.to_str().unwrap().to_string(),
809            ..PhpConfig::default()
810        };
811
812        // Empty path should resolve to index.php
813        let resolved = config.resolve_script("");
814        assert!(resolved.is_some());
815        assert!(resolved.unwrap().ends_with("index.php"));
816
817        let _ = std::fs::remove_dir_all(&tmp);
818    }
819
820    #[test]
821    fn resolve_script_dot_dot_in_component() {
822        let config = PhpConfig::default();
823        assert!(config.resolve_script("/foo/../bar.php").is_none());
824        assert!(config.resolve_script("/../index.php").is_none());
825        assert!(config.resolve_script("/a/b/../../c").is_none());
826    }
827
828    // ─── Security tests ──────────────────────────────────────────────────
829
830    #[test]
831    fn resolve_script_null_byte_blocked() {
832        let config = PhpConfig::default();
833        assert!(config.resolve_script("/index.php\0.jpg").is_none());
834        assert!(config.resolve_script("/\0/etc/passwd").is_none());
835    }
836
837    #[test]
838    fn resolve_script_symlink_escape_blocked() {
839        let tmp = std::env::temp_dir().join("bext-php-test-symlink");
840        let _ = std::fs::remove_dir_all(&tmp);
841        std::fs::create_dir_all(&tmp).unwrap();
842        std::fs::write(tmp.join("legit.php"), "<?php").unwrap();
843
844        // Create a symlink pointing outside the document root
845        #[cfg(unix)]
846        {
847            let _ = std::os::unix::fs::symlink("/etc/hostname", tmp.join("escape.php"));
848
849            let config = PhpConfig {
850                document_root: tmp.to_str().unwrap().to_string(),
851                ..PhpConfig::default()
852            };
853
854            // Legit file should resolve
855            assert!(config.resolve_script("/legit.php").is_some());
856
857            // Symlink escaping document root should be blocked
858            assert!(config.resolve_script("/escape.php").is_none());
859        }
860
861        let _ = std::fs::remove_dir_all(&tmp);
862    }
863
864    #[test]
865    fn resolve_script_encoded_traversal_blocked() {
866        let config = PhpConfig::default();
867        // URL-encoded path traversal should also be blocked if decoded
868        assert!(config.resolve_script("/..%2f..%2fetc/passwd").is_none());
869        // Double dots in any form
870        assert!(config.resolve_script("/....//....//etc/passwd").is_none());
871    }
872
873    #[test]
874    fn is_php_request_bext_internal_skipped() {
875        // /__bext/* paths should NOT be treated as PHP requests
876        // (the handler checks this, not is_php_request, but document the expectation)
877        let config = PhpConfig::default();
878        // These would match the "no extension" rule, but the handler skips them
879        assert!(config.is_php_request("/__bext/jsc-render"));
880        assert!(config.is_php_request("/__bext/php-call"));
881        // The handler adds: && !path.starts_with("/__bext/")
882    }
883
884    #[test]
885    fn ini_no_injection() {
886        let mut config = PhpConfig::default();
887        // INI values with newlines could inject settings
888        config.ini.insert("safe_key".into(), "safe_value".into());
889        config
890            .ini
891            .insert("inject\nmalicious".into(), "value".into());
892        let ini = config.ini_entries_string();
893        // The injected key appears as-is (PHP will reject it)
894        // This documents the current behavior — future: validate keys
895        assert!(ini.contains("safe_key=safe_value"));
896    }
897}