Skip to main content

ext_php_rs/
php_eval.rs

1//! Execute embedded PHP code within a running PHP extension.
2//!
3//! This module provides a way to compile and execute PHP code that has been
4//! embedded into the extension binary at compile time using `include_bytes!`.
5//!
6//! Uses `zend_compile_string` + `zend_execute` (not `zend_eval_string`)
7//! to avoid security scanner false positives and compatibility issues
8//! with hardened PHP configurations.
9//!
10//! # Example
11//!
12//! ```rust,ignore
13//! use ext_php_rs::php_eval;
14//!
15//! // Both include_bytes! and include_str! are supported:
16//! const SETUP_BYTES: &[u8] = include_bytes!("../php/setup.php");
17//! const SETUP_STR: &str = include_str!("../php/setup.php");
18//!
19//! php_eval::execute(SETUP_BYTES).expect("failed to execute embedded PHP");
20//! php_eval::execute(SETUP_STR).expect("failed to execute embedded PHP");
21//! ```
22
23use crate::ffi;
24use crate::types::ZendStr;
25use crate::zend::{CatchError, try_catch};
26use std::mem;
27use std::panic::AssertUnwindSafe;
28
29/// Errors that can occur when executing embedded PHP code.
30#[derive(Debug, thiserror::Error)]
31#[non_exhaustive]
32pub enum PhpEvalError {
33    /// The code does not start with a `<?php` open tag.
34    #[error("PHP code must start with a <?php open tag")]
35    MissingOpenTag,
36    /// PHP failed to compile the code (syntax error).
37    #[error("PHP compilation failed (syntax error)")]
38    CompilationFailed,
39    /// The code executed but threw an unhandled exception.
40    #[error("PHP execution threw an unhandled exception")]
41    ExecutionFailed,
42    /// A PHP fatal error (bailout) occurred during execution.
43    #[error("PHP fatal error (bailout) during execution")]
44    Bailout,
45    /// Rust code reached from the PHP code panicked.
46    #[error("Rust panic during execution: {0}")]
47    Panic(String),
48}
49
50/// Execute embedded PHP code within the running PHP engine.
51///
52/// The code **must** start with a `<?php` opening tag (case-insensitive),
53/// optionally preceded by a UTF-8 BOM and/or whitespace. The tag is
54/// stripped before compilation. The C wrapper uses
55/// `ZEND_COMPILE_POSITION_AFTER_OPEN_TAG` (on PHP 8.2+) so the scanner
56/// starts directly in PHP mode.
57///
58/// Error reporting is suppressed during execution and restored afterward,
59/// matching the pattern used by production PHP extensions like Blackfire.
60///
61/// # Arguments
62///
63/// * `code` - Raw PHP source, typically from `include_bytes!` or
64///   `include_str!`. Any type implementing `AsRef<[u8]>` is accepted.
65///
66/// # Errors
67///
68/// Returns [`PhpEvalError::MissingOpenTag`] if the code does not start
69/// with `<?php`. Returns other [`PhpEvalError`] variants if compilation
70/// fails, an exception is thrown, or a fatal error occurs.
71pub fn execute(code: impl AsRef<[u8]>) -> Result<(), PhpEvalError> {
72    let code = strip_bom(code.as_ref());
73    let code = strip_php_open_tag(code).ok_or(PhpEvalError::MissingOpenTag)?;
74
75    if code.is_empty() {
76        return Ok(());
77    }
78
79    let source = ZendStr::new(code, false);
80
81    // Suppress error reporting so compilation warnings from embedded
82    // code don't bubble up to the application's error handler.
83    // Saved outside `try_catch` so it is always restored, even on bailout.
84    let eg = unsafe { ffi::ext_php_rs_executor_globals() };
85    let prev_error_reporting = unsafe { mem::replace(&mut (*eg).error_reporting, 0) };
86
87    let result = try_catch(AssertUnwindSafe(|| unsafe {
88        let op_array = ffi::ext_php_rs_zend_compile_string(
89            source.as_ptr().cast_mut(),
90            c"embedded_php".as_ptr(),
91        );
92
93        if op_array.is_null() {
94            return Err(PhpEvalError::CompilationFailed);
95        }
96
97        ffi::ext_php_rs_zend_execute(op_array);
98
99        if !(*eg).exception.is_null() {
100            return Err(PhpEvalError::ExecutionFailed);
101        }
102
103        Ok(())
104    }));
105
106    unsafe { (*eg).error_reporting = prev_error_reporting };
107
108    match result {
109        Err(CatchError::Panic(message)) => Err(PhpEvalError::Panic(message)),
110        Err(_) => Err(PhpEvalError::Bailout),
111        Ok(inner) => inner,
112    }
113}
114
115fn strip_bom(code: &[u8]) -> &[u8] {
116    if code.starts_with(&[0xEF, 0xBB, 0xBF]) {
117        &code[3..]
118    } else {
119        code
120    }
121}
122
123fn strip_php_open_tag(code: &[u8]) -> Option<&[u8]> {
124    let pos = code.iter().position(|b| !b.is_ascii_whitespace())?;
125    let trimmed = &code[pos..];
126
127    if trimmed.len() >= 5 && trimmed[..5].eq_ignore_ascii_case(b"<?php") {
128        Some(trimmed[5..].trim_ascii_start())
129    } else {
130        None
131    }
132}
133
134#[cfg(feature = "embed")]
135#[cfg(test)]
136mod tests {
137    #![allow(clippy::unwrap_used)]
138    use super::*;
139    use crate::embed::Embed;
140
141    #[test]
142    fn test_execute_with_php_open_tag() {
143        Embed::run(|| {
144            let result = execute(b"<?php $x = 42;");
145            assert!(result.is_ok());
146        });
147    }
148
149    #[test]
150    fn test_execute_with_php_open_tag_and_newline() {
151        Embed::run(|| {
152            let result = execute(b"<?php\n$x = 42;");
153            assert!(result.is_ok());
154        });
155    }
156
157    #[test]
158    fn test_execute_tag_only() {
159        Embed::run(|| {
160            let result = execute(b"<?php");
161            assert!(result.is_ok());
162        });
163    }
164
165    #[test]
166    fn test_execute_exception() {
167        Embed::run(|| {
168            let result = execute(b"<?php throw new \\RuntimeException('test');");
169            assert!(matches!(result, Err(PhpEvalError::ExecutionFailed)));
170        });
171    }
172
173    #[test]
174    fn test_execute_missing_open_tag() {
175        Embed::run(|| {
176            let result = execute(b"$x = 1 + 2;");
177            assert!(matches!(result, Err(PhpEvalError::MissingOpenTag)));
178        });
179    }
180
181    #[test]
182    fn test_execute_compilation_error() {
183        Embed::run(|| {
184            let result = execute(b"<?php this is not valid php {{{");
185            assert!(matches!(result, Err(PhpEvalError::CompilationFailed)));
186        });
187    }
188
189    #[test]
190    fn test_execute_with_bom() {
191        Embed::run(|| {
192            let mut code = vec![0xEF, 0xBB, 0xBF];
193            code.extend_from_slice(b"<?php $x = 'bom_test';");
194            let result = execute(&code);
195            assert!(result.is_ok());
196        });
197    }
198
199    #[test]
200    fn test_execute_defines_variable() {
201        Embed::run(|| {
202            let result = execute(b"<?php $embed_test = 'hello from embedded php';");
203            assert!(result.is_ok());
204
205            let val = Embed::eval("$embed_test;");
206            assert!(val.is_ok());
207            assert_eq!(val.unwrap().string().unwrap(), "hello from embedded php");
208        });
209    }
210
211    #[test]
212    fn test_execute_empty_code() {
213        Embed::run(|| {
214            let result = execute(b"");
215            assert!(matches!(result, Err(PhpEvalError::MissingOpenTag)));
216        });
217    }
218
219    #[test]
220    fn test_execute_include_bytes_pattern() {
221        Embed::run(|| {
222            let code: &[u8] = b"<?php\n\
223                $embedded_value = 42;\n\
224                define('EMBEDDED_CONST', true);\n";
225            let result = execute(code);
226            assert!(result.is_ok());
227        });
228    }
229
230    #[test]
231    fn test_execute_with_str() {
232        Embed::run(|| {
233            let code: &str = "<?php $str_test = 'from_str';";
234            let result = execute(code);
235            assert!(result.is_ok());
236
237            let val = Embed::eval("$str_test;");
238            assert!(val.is_ok());
239            assert_eq!(val.unwrap().string().unwrap(), "from_str");
240        });
241    }
242
243    #[test]
244    fn test_execute_with_string() {
245        Embed::run(|| {
246            let code = String::from("<?php $string_test = 'from_string';");
247            let result = execute(code);
248            assert!(result.is_ok());
249
250            let val = Embed::eval("$string_test;");
251            assert!(val.is_ok());
252            assert_eq!(val.unwrap().string().unwrap(), "from_string");
253        });
254    }
255
256    #[test]
257    fn test_execute_with_vec() {
258        Embed::run(|| {
259            let code: Vec<u8> = b"<?php $vec_test = 'from_vec';".to_vec();
260            let result = execute(code);
261            assert!(result.is_ok());
262
263            let val = Embed::eval("$vec_test;");
264            assert!(val.is_ok());
265            assert_eq!(val.unwrap().string().unwrap(), "from_vec");
266        });
267    }
268
269    #[test]
270    fn test_strip_bom() {
271        let cases: &[(&[u8], &[u8])] = &[
272            (&[0xEF, 0xBB, 0xBF, b'h', b'i'], b"hi"),
273            (b"hello", b"hello"),
274            (b"", b""),
275        ];
276        for (input, expected) in cases {
277            assert_eq!(
278                super::strip_bom(input),
279                *expected,
280                "input: {:?}",
281                String::from_utf8_lossy(input)
282            );
283        }
284    }
285
286    #[test]
287    fn test_strip_php_open_tag() {
288        let cases: &[(&[u8], Option<&[u8]>)] = &[
289            (b"<?php $x;", Some(b"$x;")),
290            (b"<?php\n$x;", Some(b"$x;")),
291            (b"<?php\r\n$x;", Some(b"$x;")),
292            (b"<?php\t\n  $x;", Some(b"$x;")),
293            (b"<?php", Some(b"")),
294            (b"  <?php $x;", Some(b"$x;")),
295            (b"<?PHP $x;", Some(b"$x;")),
296            (b"<?Php\n$x;", Some(b"$x;")),
297            (b"", None),
298            (b"   ", None),
299            (b"$x = 1;", None),
300            (b"hello", None),
301        ];
302        for (input, expected) in cases {
303            assert_eq!(
304                super::strip_php_open_tag(input),
305                *expected,
306                "input: {:?}",
307                String::from_utf8_lossy(input)
308            );
309        }
310    }
311}