Skip to main content

ripht_php_sapi/sapi/
config.rs

1//! SAPI identity and PHP startup configuration.
2//!
3//! [`SapiConfig`] is a builder for the values PHP reads once at module
4//! startup: the SAPI name (`php_sapi_name()`), pretty name, `SERVER_SOFTWARE`,
5//! module-level INI entries, and `php.ini` discovery behavior. Apply it via
6//! [`super::RiphtSapi::configure`] before the first
7//! [`super::RiphtSapi::instance`] call. Defaults reproduce the values that
8//! were hardcoded in prior releases, so omitting configuration is a no-op.
9
10use std::ffi::{CStr, CString};
11
12use super::ffi::zend_function_entry;
13use super::native::Function as NativeFunction;
14use super::native_functions;
15use super::SapiError;
16
17const DEFAULT_SAPI_NAME: &str = "ripht";
18const DEFAULT_PRETTY_NAME: &str = "Ripht PHP SAPI";
19const DEFAULT_SERVER_SOFTWARE: &str =
20    concat!("Ripht/", env!("CARGO_PKG_VERSION"));
21
22const DEFAULT_INI_ENTRIES: &[(&str, &str)] = &[
23    ("variables_order", "EGPCS"),
24    ("request_order", "GP"),
25    ("output_buffering", "4096"),
26    ("implicit_flush", "0"),
27    ("html_errors", "0"),
28    ("display_errors", "1"),
29    ("log_errors", "1"),
30];
31
32/// SAPI identity and PHP startup configuration.
33///
34/// Apply before first [`super::RiphtSapi::instance()`] call via
35/// [`super::RiphtSapi::configure()`]. Defaults match the hardcoded
36/// values from prior releases.
37#[derive(Clone, Debug)]
38#[non_exhaustive]
39pub struct SapiConfig {
40    pub sapi_name: String,
41    pub pretty_name: String,
42    pub server_software: String,
43    pub ini_entries: Vec<(String, String)>,
44    pub ignore_php_ini: bool,
45    pub ignore_cwd_ini: bool,
46    pub ini_path: Option<String>,
47    native_functions: Vec<zend_function_entry>,
48}
49
50impl Default for SapiConfig {
51    fn default() -> Self {
52        Self {
53            sapi_name: DEFAULT_SAPI_NAME.to_owned(),
54            pretty_name: DEFAULT_PRETTY_NAME.to_owned(),
55            server_software: DEFAULT_SERVER_SOFTWARE.to_owned(),
56            ini_entries: DEFAULT_INI_ENTRIES
57                .iter()
58                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
59                .collect(),
60            ignore_php_ini: false,
61            ignore_cwd_ini: true,
62            ini_path: None,
63            native_functions: Vec::new(),
64        }
65    }
66}
67
68impl SapiConfig {
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    #[must_use]
75    pub fn sapi_name(mut self, name: impl Into<String>) -> Self {
76        self.sapi_name = name.into();
77        self
78    }
79
80    #[must_use]
81    pub fn pretty_name(mut self, name: impl Into<String>) -> Self {
82        self.pretty_name = name.into();
83        self
84    }
85
86    #[must_use]
87    pub fn server_software(mut self, software: impl Into<String>) -> Self {
88        self.server_software = software.into();
89        self
90    }
91
92    #[must_use]
93    pub fn ini_entries(mut self, entries: Vec<(String, String)>) -> Self {
94        self.ini_entries = entries;
95        self
96    }
97
98    #[must_use]
99    pub fn ini_entry(
100        mut self,
101        key: impl Into<String>,
102        value: impl Into<String>,
103    ) -> Self {
104        self.ini_entries
105            .push((key.into(), value.into()));
106        self
107    }
108
109    #[must_use]
110    pub fn ignore_php_ini(mut self, ignore: bool) -> Self {
111        self.ignore_php_ini = ignore;
112        self
113    }
114
115    #[must_use]
116    pub fn ignore_cwd_ini(mut self, ignore: bool) -> Self {
117        self.ignore_cwd_ini = ignore;
118        self
119    }
120
121    #[must_use]
122    pub fn ini_path(mut self, path: impl Into<String>) -> Self {
123        self.ini_path = Some(path.into());
124        self
125    }
126
127    #[must_use]
128    pub fn native_functions(
129        mut self,
130        entries: &'static [NativeFunction],
131    ) -> Self {
132        self.native_functions = entries
133            .iter()
134            .copied()
135            .map(NativeFunction::entry)
136            .take_while(|entry| !entry.fname.is_null())
137            .collect();
138        self
139    }
140
141    /// Validate the configuration and leak its strings to `'static`.
142    ///
143    /// PHP holds the SAPI module's `name`, `pretty_name`, `ini_entries`, and
144    /// `php_ini_path_override` pointers for the lifetime of the process, so the
145    /// resolved buffers are intentionally leaked (`Box::leak`) rather than
146    /// owned. Call this exactly once, from the one-time engine init in
147    /// [`super::RiphtSapi`]; repeated calls leak additional memory.
148    pub(crate) fn resolve(self) -> Result<ResolvedConfig, SapiError> {
149        fn leak_null_terminated(s: String) -> &'static [u8] {
150            let mut bytes = s.into_bytes();
151            bytes.push(0);
152            Box::leak(bytes.into_boxed_slice())
153        }
154
155        if self.sapi_name.contains('\0') {
156            return Err(SapiError::InvalidSapiName(self.sapi_name));
157        }
158        let sapi_name = leak_null_terminated(self.sapi_name);
159
160        if self
161            .pretty_name
162            .contains('\0')
163        {
164            return Err(SapiError::InvalidPrettyName(self.pretty_name));
165        }
166        let pretty_name = leak_null_terminated(self.pretty_name);
167
168        if self
169            .server_software
170            .contains('\0')
171        {
172            return Err(SapiError::InvalidServerSoftware(self.server_software));
173        }
174        let server_software: &'static str = Box::leak(
175            self.server_software
176                .into_boxed_str(),
177        );
178
179        let mut ini_blob = String::new();
180        for (key, value) in &self.ini_entries {
181            if key.contains('\0') {
182                return Err(SapiError::InvalidIniKey);
183            }
184            if value.contains('\0') {
185                return Err(SapiError::InvalidIniValue);
186            }
187            ini_blob.push_str(key);
188            ini_blob.push('=');
189            ini_blob.push_str(value);
190            ini_blob.push('\n');
191        }
192        let ini_entries = leak_null_terminated(ini_blob);
193
194        let ini_path = match self.ini_path {
195            Some(path) => {
196                let cstring = CString::new(path)
197                    .map_err(|_| SapiError::InvalidIniPath)?;
198                Some(Box::leak(cstring.into_boxed_c_str()) as &'static CStr)
199            }
200            None => None,
201        };
202
203        let mut native_entries = if self
204            .native_functions
205            .is_empty()
206        {
207            native_functions::entries()
208                .iter()
209                .copied()
210                .take_while(|entry| !entry.fname.is_null())
211                .collect()
212        } else {
213            self.native_functions
214        };
215        native_entries.push(zend_function_entry::end());
216        let native_functions = Box::leak(native_entries.into_boxed_slice());
217
218        Ok(ResolvedConfig {
219            sapi_name,
220            pretty_name,
221            server_software,
222            ini_entries,
223            ignore_php_ini: self.ignore_php_ini,
224            ignore_cwd_ini: self.ignore_cwd_ini,
225            ini_path,
226            native_functions,
227        })
228    }
229}
230
231/// Process-lifetime C-compatible pointers for the SAPI module struct.
232/// Created via [`SapiConfig::resolve`] and stored in a `OnceLock`.
233pub(crate) struct ResolvedConfig {
234    pub sapi_name: &'static [u8],
235    pub pretty_name: &'static [u8],
236    pub server_software: &'static str,
237    pub ini_entries: &'static [u8],
238    pub ignore_php_ini: bool,
239    pub ignore_cwd_ini: bool,
240    pub ini_path: Option<&'static CStr>,
241    pub native_functions: &'static [zend_function_entry],
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use std::ffi::CStr;
248    use std::os::raw::{c_char, c_void};
249
250    use super::super::native::ReturnValue;
251
252    unsafe extern "C" fn fake_handler(
253        _execute_data: *mut c_void,
254        _return_value: *mut ReturnValue,
255    ) {
256    }
257
258    #[test]
259    fn default_sapi_name() {
260        assert_eq!(SapiConfig::default().sapi_name, "ripht");
261    }
262
263    #[test]
264    fn default_pretty_name() {
265        assert_eq!(SapiConfig::default().pretty_name, "Ripht PHP SAPI");
266    }
267
268    #[test]
269    fn default_server_software_prefix() {
270        assert!(SapiConfig::default()
271            .server_software
272            .starts_with("Ripht/"));
273    }
274
275    #[test]
276    fn default_ini_entries_count() {
277        assert_eq!(
278            SapiConfig::default()
279                .ini_entries
280                .len(),
281            7
282        );
283    }
284
285    #[test]
286    fn default_ini_contains_variables_order() {
287        let config = SapiConfig::default();
288        assert!(config
289            .ini_entries
290            .contains(&("variables_order".to_owned(), "EGPCS".to_owned())));
291    }
292
293    #[test]
294    fn default_ignore_flags() {
295        let config = SapiConfig::default();
296        assert!(!config.ignore_php_ini);
297        assert!(config.ignore_cwd_ini);
298    }
299
300    #[test]
301    fn builder_chaining() {
302        let config = SapiConfig::new()
303            .sapi_name("cli")
304            .pretty_name("Test SAPI")
305            .server_software("Test/1.0")
306            .ignore_php_ini(true)
307            .ignore_cwd_ini(false)
308            .ini_path("/etc/php.ini");
309
310        assert_eq!(config.sapi_name, "cli");
311        assert_eq!(config.pretty_name, "Test SAPI");
312        assert_eq!(config.server_software, "Test/1.0");
313        assert!(config.ignore_php_ini);
314        assert!(!config.ignore_cwd_ini);
315        assert_eq!(config.ini_path.as_deref(), Some("/etc/php.ini"));
316    }
317
318    #[test]
319    fn ini_entry_appends() {
320        let config = SapiConfig::new().ini_entry("custom_key", "custom_value");
321        assert_eq!(config.ini_entries.len(), 8);
322        assert_eq!(
323            config
324                .ini_entries
325                .last()
326                .unwrap(),
327            &("custom_key".to_owned(), "custom_value".to_owned())
328        );
329    }
330
331    #[test]
332    fn ini_entries_replaces_all() {
333        let config = SapiConfig::new()
334            .ini_entries(vec![("only".to_owned(), "one".to_owned())]);
335        assert_eq!(config.ini_entries.len(), 1);
336    }
337
338    #[test]
339    fn resolve_null_terminates_sapi_name() {
340        let resolved = SapiConfig::new()
341            .sapi_name("test")
342            .resolve()
343            .unwrap();
344        assert_eq!(resolved.sapi_name, b"test\0");
345    }
346
347    #[test]
348    fn resolve_null_terminates_pretty_name() {
349        let resolved = SapiConfig::new()
350            .pretty_name("My SAPI")
351            .resolve()
352            .unwrap();
353        assert_eq!(resolved.pretty_name, b"My SAPI\0");
354    }
355
356    #[test]
357    fn resolve_ini_blob_format() {
358        let resolved = SapiConfig::new()
359            .ini_entries(vec![
360                ("key1".to_owned(), "val1".to_owned()),
361                ("key2".to_owned(), "val2".to_owned()),
362            ])
363            .resolve()
364            .unwrap();
365        assert_eq!(resolved.ini_entries, b"key1=val1\nkey2=val2\n\0");
366    }
367
368    #[test]
369    fn resolve_empty_ini_blob() {
370        let resolved = SapiConfig::new()
371            .ini_entries(vec![])
372            .resolve()
373            .unwrap();
374        assert_eq!(resolved.ini_entries, b"\0");
375    }
376
377    #[test]
378    fn resolve_rejects_null_in_sapi_name() {
379        let result = SapiConfig::new()
380            .sapi_name("te\0st")
381            .resolve();
382        assert!(matches!(result, Err(SapiError::InvalidSapiName(_))));
383    }
384
385    #[test]
386    fn resolve_rejects_null_in_pretty_name() {
387        let result = SapiConfig::new()
388            .pretty_name("My\0SAPI")
389            .resolve();
390        assert!(matches!(result, Err(SapiError::InvalidPrettyName(_))));
391    }
392
393    #[test]
394    fn resolve_rejects_null_in_server_software() {
395        let result = SapiConfig::new()
396            .server_software("Bad\0/1.0")
397            .resolve();
398        assert!(matches!(result, Err(SapiError::InvalidServerSoftware(_))));
399    }
400
401    #[test]
402    fn resolve_rejects_null_in_ini_key() {
403        let result = SapiConfig::new()
404            .ini_entries(vec![("ke\0y".to_owned(), "val".to_owned())])
405            .resolve();
406        assert!(matches!(result, Err(SapiError::InvalidIniKey)));
407    }
408
409    #[test]
410    fn resolve_rejects_null_in_ini_value() {
411        let result = SapiConfig::new()
412            .ini_entries(vec![("key".to_owned(), "va\0l".to_owned())])
413            .resolve();
414        assert!(matches!(result, Err(SapiError::InvalidIniValue)));
415    }
416
417    #[test]
418    fn resolve_rejects_null_in_ini_path() {
419        let result = SapiConfig::new()
420            .ini_path("/etc/ph\0p.ini")
421            .resolve();
422        assert!(matches!(result, Err(SapiError::InvalidIniPath)));
423    }
424
425    #[test]
426    fn resolve_ini_path_produces_valid_cstr() {
427        let resolved = SapiConfig::new()
428            .ini_path("/etc/php.ini")
429            .resolve()
430            .unwrap();
431        let cstr = resolved.ini_path.unwrap();
432        assert_eq!(cstr.to_str().unwrap(), "/etc/php.ini");
433    }
434
435    #[test]
436    fn resolve_defaults_match_prior_constants() {
437        let resolved = SapiConfig::default()
438            .resolve()
439            .unwrap();
440        assert_eq!(resolved.sapi_name, b"ripht\0");
441        assert_eq!(resolved.pretty_name, b"Ripht PHP SAPI\0");
442        assert!(resolved
443            .server_software
444            .starts_with("Ripht/"));
445        assert!(!resolved.ignore_php_ini);
446        assert!(resolved.ignore_cwd_ini);
447        assert!(resolved.ini_path.is_none());
448
449        let ini = std::str::from_utf8(
450            &resolved.ini_entries[..resolved.ini_entries.len() - 1],
451        )
452        .unwrap();
453        assert!(ini.contains("variables_order=EGPCS"));
454        assert!(ini.contains("log_errors=1"));
455    }
456
457    #[test]
458    fn resolve_uses_custom_native_functions_before_terminator() {
459        static CUSTOM: [NativeFunction; 1] = [
460            // SAFETY: the function name is static, this test does not execute the
461            // handler, and the null arginfo pointer is only used to verify table wiring.
462            unsafe {
463                NativeFunction::new_unchecked(
464                    b"ripht_test_native\0".as_ptr() as *const c_char,
465                    fake_handler,
466                    std::ptr::null(),
467                    0,
468                )
469            },
470        ];
471
472        let resolved = SapiConfig::new()
473            .native_functions(&CUSTOM)
474            .resolve()
475            .unwrap();
476
477        let names = resolved
478            .native_functions
479            .iter()
480            .take_while(|entry| !entry.fname.is_null())
481            .map(|entry| {
482                unsafe { CStr::from_ptr(entry.fname) }
483                    .to_string_lossy()
484                    .into_owned()
485            })
486            .collect::<Vec<_>>();
487
488        assert_eq!(names, vec!["ripht_test_native"]);
489        assert!(resolved
490            .native_functions
491            .last()
492            .unwrap()
493            .fname
494            .is_null());
495    }
496}