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