Skip to main content

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;
26pub mod config;
27mod executor;
28pub(crate) mod ffi;
29pub mod native;
30pub(crate) mod native_functions;
31pub(crate) mod server_context;
32pub(crate) mod server_vars;
33
34pub use config::SapiConfig;
35pub use executor::{ExecutionError, Executor};
36pub(crate) use server_vars::{ServerVars, ServerVarsCString};
37
38use crate::execution::{ExecutionContext, ExecutionHooks, ExecutionResult};
39use config::ResolvedConfig;
40
41static PHP_INIT_RESULT: OnceLock<Result<(), SapiError>> = OnceLock::new();
42static SAPI_CONFIG: OnceLock<SapiConfig> = OnceLock::new();
43pub(crate) static RESOLVED_CONFIG: OnceLock<ResolvedConfig> = OnceLock::new();
44
45/// Errors from SAPI initialization and configuration.
46#[derive(Debug, Clone, Error)]
47#[non_exhaustive]
48pub enum SapiError {
49    #[error("PHP engine not initialized")]
50    NotInitialized,
51
52    #[error("PHP initialization failed: {0}")]
53    InitializationFailed(String),
54
55    #[error("PHP engine already initialized — configure() must be called before instance()")]
56    AlreadyInitialized,
57
58    #[error("SAPI already configured — configure() can only be called once")]
59    AlreadyConfigured,
60
61    #[error("SAPI name contains null byte: {0:?}")]
62    InvalidSapiName(String),
63
64    #[error("pretty name contains null byte: {0:?}")]
65    InvalidPrettyName(String),
66
67    #[error("server software string contains null byte: {0:?}")]
68    InvalidServerSoftware(String),
69
70    #[error("INI key contains null byte")]
71    InvalidIniKey,
72
73    #[error("INI value contains null byte")]
74    InvalidIniValue,
75
76    #[error("INI path contains null byte")]
77    InvalidIniPath,
78
79    #[error("Failed to set INI: {0}")]
80    IniSetFailed(String),
81
82    #[error(
83        "PHP library not found. Build PHP with --enable-embed=static and set RIPHT_PHP_SAPI_PREFIX"
84    )]
85    LibraryNotFound,
86}
87
88/// PHP SAPI instance. Initialize once, execute scripts repeatedly.
89pub struct RiphtSapi {
90    _marker: std::marker::PhantomData<*mut ()>,
91}
92
93impl RiphtSapi {
94    /// Apply custom SAPI configuration before the first [`RiphtSapi::instance()`] call.
95    ///
96    /// Must be called before PHP is initialized. Returns
97    /// [`SapiError::AlreadyInitialized`] if PHP is already running or
98    /// [`SapiError::AlreadyConfigured`] if `configure` was already called.
99    ///
100    /// Call this from a single thread during startup. `configure` and
101    /// `instance` are not internally synchronized against each other: a
102    /// `configure` racing a concurrent `instance` may be silently ignored
103    /// (the engine reads defaults before the config lands). This matches the
104    /// non-ZTS, single-threaded-init contract of the crate.
105    pub fn configure(config: SapiConfig) -> Result<(), SapiError> {
106        if PHP_INIT_RESULT
107            .get()
108            .is_some()
109        {
110            return Err(SapiError::AlreadyInitialized);
111        }
112        SAPI_CONFIG
113            .set(config)
114            .map_err(|_| SapiError::AlreadyConfigured)
115    }
116
117    // Note: will panic if initialization fails
118    #[must_use]
119    pub fn instance() -> Self {
120        Self::init().expect("SAPI initialization failure")
121    }
122
123    fn init() -> Result<Self, SapiError> {
124        let init_result = PHP_INIT_RESULT.get_or_init(|| {
125            #[cfg(feature = "tracing")]
126            info!("Initializing RiphtSapi");
127
128            let config = SAPI_CONFIG
129                .get()
130                .cloned()
131                .unwrap_or_default();
132            let resolved = config.resolve()?;
133            let resolved = RESOLVED_CONFIG.get_or_init(|| resolved);
134
135            // SAFETY: One-time PHP engine initialization via OnceLock.
136            // All pointers/callbacks are static or 'static and remain valid.
137            // ResolvedConfig fields are Box::leak'd to 'static.
138            unsafe {
139                ffi::sapi_module.name = resolved.sapi_name.as_ptr() as *mut _;
140                ffi::sapi_module.pretty_name =
141                    resolved.pretty_name.as_ptr() as *mut _;
142
143                // Register callbacks
144                ffi::sapi_module.startup = Some(callbacks::ripht_sapi_startup);
145                ffi::sapi_module.shutdown =
146                    Some(callbacks::ripht_sapi_shutdown);
147                ffi::sapi_module.activate =
148                    Some(callbacks::ripht_sapi_activate);
149                ffi::sapi_module.deactivate =
150                    Some(callbacks::ripht_sapi_deactivate);
151
152                ffi::sapi_module.ub_write =
153                    Some(callbacks::ripht_sapi_ub_write);
154                ffi::sapi_module.flush = Some(callbacks::ripht_sapi_flush);
155
156                ffi::sapi_module.send_headers =
157                    Some(callbacks::ripht_sapi_send_headers);
158                ffi::sapi_module.send_header =
159                    Some(callbacks::ripht_sapi_send_header);
160
161                ffi::sapi_module.read_post =
162                    Some(callbacks::ripht_sapi_read_post);
163                ffi::sapi_module.read_cookies =
164                    Some(callbacks::ripht_sapi_read_cookies);
165
166                ffi::sapi_module.register_server_variables =
167                    Some(callbacks::ripht_sapi_register_server_variables);
168
169                ffi::sapi_module.log_message =
170                    Some(callbacks::ripht_sapi_log_message);
171                ffi::sapi_module.get_request_time =
172                    Some(callbacks::ripht_sapi_get_request_time);
173                ffi::sapi_module.getenv = Some(callbacks::ripht_sapi_getenv);
174
175                ffi::sapi_module.php_ini_ignore =
176                    i32::from(resolved.ignore_php_ini);
177                ffi::sapi_module.php_ini_ignore_cwd =
178                    i32::from(resolved.ignore_cwd_ini);
179
180                if let Some(ini_path) = resolved.ini_path {
181                    ffi::sapi_module.php_ini_path_override =
182                        ini_path.as_ptr() as *mut _;
183                }
184
185                ffi::sapi_module.input_filter =
186                    Some(callbacks::ripht_sapi_input_filter);
187                ffi::sapi_module.default_post_reader =
188                    Some(callbacks::ripht_sapi_default_post_reader);
189                ffi::sapi_module.treat_data =
190                    Some(callbacks::ripht_sapi_treat_data);
191
192                ffi::sapi_module.ini_entries =
193                    resolved.ini_entries.as_ptr() as *const _;
194
195                ffi::sapi_module.additional_functions = resolved
196                    .native_functions
197                    .as_ptr();
198
199                #[cfg(feature = "tracing")]
200                trace!("Starting SAPI");
201
202                ffi::sapi_startup(&mut ffi::sapi_module);
203
204                #[cfg(feature = "tracing")]
205                trace!("Initializing SAPI module");
206
207                let result = ffi::php_module_startup(
208                    &mut ffi::sapi_module,
209                    std::ptr::null_mut(),
210                );
211
212                if result == ffi::FAILURE {
213                    #[cfg(feature = "tracing")]
214                    error!("SAPI module startup failed");
215
216                    ffi::sapi_shutdown();
217
218                    Err(SapiError::InitializationFailed(
219                        "SAPI module initialization failed".to_string(),
220                    ))
221                } else {
222                    #[cfg(feature = "tracing")]
223                    info!("SAPI module initialized");
224                    Ok(())
225                }
226            }
227        });
228
229        match init_result {
230            Ok(()) => Ok(Self {
231                _marker: std::marker::PhantomData,
232            }),
233            // Clone the original error instead of wrapping it redundantly.
234            // The error already contains descriptive context.
235            Err(e) => Err(e.clone()),
236        }
237    }
238
239    /// Shuts down the PHP engine. Calling `execute()` after this is undefined behavior.
240    pub fn shutdown() {
241        unsafe {
242            ffi::php_module_shutdown();
243            ffi::sapi_shutdown();
244        }
245    }
246
247    pub fn set_ini(
248        &self,
249        key: impl Into<Vec<u8>>,
250        value: impl Into<Vec<u8>>,
251    ) -> Result<(), SapiError> {
252        let k_str = key.into();
253        let v_str = value.into();
254
255        let key_cstr = CString::new(k_str.clone())
256            .map_err(|_| SapiError::InvalidIniKey)?;
257        let value_cstr = CString::new(v_str.clone())
258            .map_err(|_| SapiError::InvalidIniValue)?;
259
260        unsafe {
261            let init = ffi::zend_string_init_interned
262                .expect("zend_string_init_interned is null");
263
264            let name = init(key_cstr.as_ptr(), k_str.len(), true);
265
266            if name.is_null() {
267                return Err(SapiError::IniSetFailed(
268                    String::from_utf8(k_str).unwrap_or_default(),
269                ));
270            }
271
272            let result = ffi::zend_alter_ini_entry_chars(
273                name,
274                value_cstr.as_ptr(),
275                v_str.len(),
276                ffi::ZEND_INI_USER | ffi::ZEND_INI_SYSTEM,
277                ffi::ZEND_INI_STAGE_RUNTIME,
278            );
279
280            if result != ffi::SUCCESS {
281                return Err(SapiError::IniSetFailed(
282                    String::from_utf8(k_str).unwrap_or_default(),
283                ));
284            }
285
286            Ok(())
287        }
288    }
289
290    pub fn get_ini(&self, key: &str) -> Option<String> {
291        #[cfg(feature = "tracing")]
292        trace!(ini_key = key, "Getting INI value");
293
294        let key_cstr = CString::new(key).ok()?;
295
296        unsafe {
297            let ptr = ffi::zend_ini_string(key_cstr.as_ptr(), key.len(), 0);
298            if ptr.is_null() {
299                None
300            } else {
301                Some(
302                    std::ffi::CStr::from_ptr(ptr)
303                        .to_string_lossy()
304                        .into_owned(),
305                )
306            }
307        }
308    }
309
310    pub fn executor(&self) -> Result<Executor<'_>, SapiError> {
311        Executor::new(self)
312    }
313
314    pub fn execute(
315        &self,
316        ctx: ExecutionContext,
317    ) -> Result<ExecutionResult, ExecutionError> {
318        self.executor()
319            .map_err(|_| ExecutionError::NotInitialized)?
320            .execute(ctx)
321    }
322
323    pub fn execute_streaming<F>(
324        &self,
325        ctx: ExecutionContext,
326        on_output: F,
327    ) -> Result<ExecutionResult, ExecutionError>
328    where
329        F: FnMut(&[u8]) + 'static,
330    {
331        self.executor()
332            .map_err(|_| ExecutionError::NotInitialized)?
333            .execute_streaming(ctx, on_output)
334    }
335
336    pub fn execute_with_hooks<H: ExecutionHooks + 'static>(
337        &self,
338        ctx: ExecutionContext,
339        hooks: H,
340    ) -> Result<ExecutionResult, ExecutionError> {
341        self.executor()
342            .map_err(|_| ExecutionError::NotInitialized)?
343            .execute_with_hooks(ctx, hooks)
344    }
345
346    pub fn is_initialized(&self) -> bool {
347        PHP_INIT_RESULT
348            .get()
349            .map(|result| result.is_ok())
350            .unwrap_or(false)
351    }
352}