ripht_php_sapi/sapi/
mod.rs

1//! Core SAPI implementation and PHP lifecycle management.
2//!
3//! Handles module startup/shutdown (MINIT/MSHUTDOWN), callback registration,
4//! and provides the primary `RiphtSapi` interface for script execution.
5//!
6//! Adheres to the Common Gateway Interface (CGI) Version 1.1 specification for environment variable semantics.
7//!
8//! ## Specification Compliance
9//!
10//! This SAPI implements CGI/1.1 meta-variable conventions as defined in:
11//! - [RFC 3875 - The Common Gateway Interface (CGI) Version 1.1](https://datatracker.ietf.org/doc/html/rfc3875)
12//!
13//! Specifically:
14//! - Section 4.1: Request Meta-Variables
15//! - Section 4.1.4: `GATEWAY_INTERFACE` set to `CGI/1.1`
16
17use std::ffi::CString;
18use std::sync::OnceLock;
19
20use thiserror::Error;
21
22#[cfg(feature = "tracing")]
23use tracing::{error, info, trace};
24
25pub(crate) mod callbacks;
26mod executor;
27pub(crate) mod ffi;
28pub(crate) mod server_context;
29pub(crate) mod server_vars;
30
31pub use executor::{ExecutionError, Executor};
32pub(crate) use server_vars::{ServerVars, ServerVarsCString};
33
34use crate::execution::{ExecutionContext, ExecutionHooks, ExecutionResult};
35
36static PHP_INIT_RESULT: OnceLock<Result<(), SapiError>> = OnceLock::new();
37
38pub(crate) static SAPI_NAME: &[u8] = b"ripht\0";
39pub(crate) static SAPI_PRETTY_NAME: &[u8] = b"Ripht PHP SAPI\0";
40pub(crate) static SERVER_SOFTWARE: &str =
41    concat!("Ripht/", env!("CARGO_PKG_VERSION"));
42static INI_ENTRIES: &[u8] = b"\
43variables_order=EGPCS\n\
44request_order=GP\n\
45output_buffering=4096\n\
46implicit_flush=0\n\
47html_errors=0\n\
48display_errors=1\n\
49log_errors=1\n\
50\0";
51
52/// Errors from SAPI initialization and configuration.
53#[derive(Debug, Clone, Error)]
54#[non_exhaustive]
55pub enum SapiError {
56    #[error("PHP engine not initialized")]
57    NotInitialized,
58
59    #[error("PHP initialization failed: {0}")]
60    InitializationFailed(String),
61
62    #[error("INI key contains null byte")]
63    InvalidIniKey,
64
65    #[error("INI value contains null byte")]
66    InvalidIniValue,
67
68    #[error("Failed to set INI: {0}")]
69    IniSetFailed(String),
70
71    #[error(
72        "PHP library not found. Build PHP with --enable-embed=static and set RIPHT_PHP_SAPI_PREFIX"
73    )]
74    LibraryNotFound,
75}
76
77/// PHP SAPI instance. Initialize once, execute scripts repeatedly.
78pub struct RiphtSapi {
79    _marker: std::marker::PhantomData<*mut ()>,
80}
81
82impl RiphtSapi {
83    // Note: will panic if initialization fails
84    #[must_use]
85    pub fn instance() -> Self {
86        Self::init().expect("SAPI initialization failure")
87    }
88
89    fn init() -> Result<Self, SapiError> {
90        let init_result = PHP_INIT_RESULT.get_or_init(|| {
91            #[cfg(feature = "tracing")]
92            info!("Initializing RiphtSapi");
93
94            // SAFETY: One-time PHP engine initialization via OnceLock.
95            // All pointers/callbacks are static or 'static and remain valid.
96            unsafe {
97                ffi::sapi_module.name = SAPI_NAME.as_ptr() as *mut _;
98                ffi::sapi_module.pretty_name =
99                    SAPI_PRETTY_NAME.as_ptr() as *mut _;
100
101                // Register callbacks
102                ffi::sapi_module.startup = Some(callbacks::ripht_sapi_startup);
103                ffi::sapi_module.shutdown =
104                    Some(callbacks::ripht_sapi_shutdown);
105                ffi::sapi_module.activate =
106                    Some(callbacks::ripht_sapi_activate);
107                ffi::sapi_module.deactivate =
108                    Some(callbacks::ripht_sapi_deactivate);
109
110                ffi::sapi_module.ub_write =
111                    Some(callbacks::ripht_sapi_ub_write);
112                ffi::sapi_module.flush = Some(callbacks::ripht_sapi_flush);
113
114                ffi::sapi_module.send_headers =
115                    Some(callbacks::ripht_sapi_send_headers);
116                ffi::sapi_module.send_header =
117                    Some(callbacks::ripht_sapi_send_header);
118
119                ffi::sapi_module.read_post =
120                    Some(callbacks::ripht_sapi_read_post);
121                ffi::sapi_module.read_cookies =
122                    Some(callbacks::ripht_sapi_read_cookies);
123
124                ffi::sapi_module.register_server_variables =
125                    Some(callbacks::ripht_sapi_register_server_variables);
126
127                ffi::sapi_module.log_message =
128                    Some(callbacks::ripht_sapi_log_message);
129                ffi::sapi_module.get_request_time =
130                    Some(callbacks::ripht_sapi_get_request_time);
131                ffi::sapi_module.getenv = Some(callbacks::ripht_sapi_getenv);
132
133                ffi::sapi_module.php_ini_ignore = 0;
134                ffi::sapi_module.php_ini_ignore_cwd = 1;
135
136                ffi::sapi_module.input_filter =
137                    Some(callbacks::ripht_sapi_input_filter);
138                ffi::sapi_module.default_post_reader =
139                    Some(callbacks::ripht_sapi_default_post_reader);
140                ffi::sapi_module.treat_data =
141                    Some(callbacks::ripht_sapi_treat_data);
142
143                ffi::sapi_module.ini_entries = INI_ENTRIES.as_ptr() as *const _;
144
145                #[cfg(feature = "tracing")]
146                trace!("Starting SAPI");
147
148                ffi::sapi_startup(&mut ffi::sapi_module);
149
150                #[cfg(feature = "tracing")]
151                trace!("Initializing SAPI module");
152
153                let result = ffi::php_module_startup(
154                    &mut ffi::sapi_module,
155                    std::ptr::null_mut(),
156                );
157
158                if result == ffi::FAILURE {
159                    #[cfg(feature = "tracing")]
160                    error!("SAPI module startup failed");
161
162                    ffi::sapi_shutdown();
163
164                    Err(SapiError::InitializationFailed(
165                        "SAPI module initialization failed".to_string(),
166                    ))
167                } else {
168                    #[cfg(feature = "tracing")]
169                    info!("SAPI module initialized");
170                    Ok(())
171                }
172            }
173        });
174
175        match init_result {
176            Ok(()) => Ok(Self {
177                _marker: std::marker::PhantomData,
178            }),
179            // Clone the original error instead of wrapping it redundantly.
180            // The error already contains descriptive context.
181            Err(e) => Err(e.clone()),
182        }
183    }
184
185    /// Shuts down the PHP engine. Calling `execute()` after this is undefined behavior.
186    pub fn shutdown() {
187        unsafe {
188            ffi::php_module_shutdown();
189            ffi::sapi_shutdown();
190        }
191    }
192
193    pub fn set_ini(
194        &self,
195        key: impl Into<Vec<u8>>,
196        value: impl Into<Vec<u8>>,
197    ) -> Result<(), SapiError> {
198        let k_str = key.into();
199        let v_str = value.into();
200
201        let key_cstr = CString::new(k_str.clone())
202            .map_err(|_| SapiError::InvalidIniKey)?;
203        let value_cstr = CString::new(v_str.clone())
204            .map_err(|_| SapiError::InvalidIniValue)?;
205
206        unsafe {
207            let init = ffi::zend_string_init_interned
208                .expect("zend_string_init_interned is null");
209
210            let name = init(key_cstr.as_ptr(), k_str.len(), true);
211
212            if name.is_null() {
213                return Err(SapiError::IniSetFailed(
214                    String::from_utf8(k_str).unwrap_or_default(),
215                ));
216            }
217
218            let result = ffi::zend_alter_ini_entry_chars(
219                name,
220                value_cstr.as_ptr(),
221                v_str.len(),
222                ffi::ZEND_INI_USER | ffi::ZEND_INI_SYSTEM,
223                ffi::ZEND_INI_STAGE_RUNTIME,
224            );
225
226            if result != ffi::SUCCESS {
227                return Err(SapiError::IniSetFailed(
228                    String::from_utf8(k_str).unwrap_or_default(),
229                ));
230            }
231
232            Ok(())
233        }
234    }
235
236    pub fn get_ini(&self, key: &str) -> Option<String> {
237        #[cfg(feature = "tracing")]
238        trace!(ini_key = key, "Getting INI value");
239
240        let key_cstr = CString::new(key).ok()?;
241
242        unsafe {
243            let ptr = ffi::zend_ini_string(key_cstr.as_ptr(), key.len(), 0);
244            if ptr.is_null() {
245                None
246            } else {
247                Some(
248                    std::ffi::CStr::from_ptr(ptr)
249                        .to_string_lossy()
250                        .into_owned(),
251                )
252            }
253        }
254    }
255
256    pub fn executor(&self) -> Result<Executor<'_>, SapiError> {
257        Executor::new(self)
258    }
259
260    pub fn execute(
261        &self,
262        ctx: ExecutionContext,
263    ) -> Result<ExecutionResult, ExecutionError> {
264        self.executor()
265            .map_err(|_| ExecutionError::NotInitialized)?
266            .execute(ctx)
267    }
268
269    pub fn execute_streaming<F>(
270        &self,
271        ctx: ExecutionContext,
272        on_output: F,
273    ) -> Result<ExecutionResult, ExecutionError>
274    where
275        F: FnMut(&[u8]) + 'static,
276    {
277        self.executor()
278            .map_err(|_| ExecutionError::NotInitialized)?
279            .execute_streaming(ctx, on_output)
280    }
281
282    pub fn execute_with_hooks<H: ExecutionHooks + 'static>(
283        &self,
284        ctx: ExecutionContext,
285        hooks: H,
286    ) -> Result<ExecutionResult, ExecutionError> {
287        self.executor()
288            .map_err(|_| ExecutionError::NotInitialized)?
289            .execute_with_hooks(ctx, hooks)
290    }
291
292    pub fn is_initialized(&self) -> bool {
293        PHP_INIT_RESULT
294            .get()
295            .map(|result| result.is_ok())
296            .unwrap_or(false)
297    }
298}