Skip to main content

ext_php_rs/zend/
headers.rs

1//! Response headers and status code, the way `header()`, `header_remove()` and
2//! `http_response_code()` set them.
3
4use std::{ffi::c_void, mem, ptr};
5
6use crate::{
7    error::{Error, Result},
8    ffi::{
9        ZEND_RESULT_CODE_SUCCESS, ext_php_rs_output_activated, sapi_header_line, sapi_header_op,
10        sapi_header_op_enum, sapi_header_op_enum_SAPI_HEADER_ADD,
11        sapi_header_op_enum_SAPI_HEADER_DELETE, sapi_header_op_enum_SAPI_HEADER_DELETE_ALL,
12        sapi_header_op_enum_SAPI_HEADER_REPLACE, sapi_header_op_enum_SAPI_HEADER_SET_STATUS,
13    },
14    zend::globals::lock,
15};
16
17/// Sets a response header and removes the headers with the same name, like
18/// `header($header)`.
19///
20/// `header` is a full line, for example `"Content-Type: text/plain"`. PHP
21/// applies its usual rules: a `Location` header sets the status to `302` (or
22/// `303` for a non `GET`/`HEAD` request) unless it is already `201` or `3xx`,
23/// and an `HTTP/` line sets the status line. Call [`set_response_code`] after
24/// a `Location` header to send another redirect code.
25///
26/// # Errors
27///
28/// [`Error::ResponseHeaderFailed`] if no request is active (for example during
29/// `MINIT`), if a [`SapiGlobals`](crate::zend::SapiGlobals) guard is alive, if
30/// `header` is empty, or if PHP refuses the header. PHP emits a warning when
31/// the headers are already sent or when `header` contains a new line or a NUL
32/// byte.
33pub fn set_header(header: &str) -> Result<()> {
34    line_op(sapi_header_op_enum_SAPI_HEADER_REPLACE, header)
35}
36
37/// Adds a response header and keeps the headers with the same name, like
38/// `header($header, false)`.
39///
40/// # Errors
41///
42/// See [`set_header`].
43pub fn add_header(header: &str) -> Result<()> {
44    line_op(sapi_header_op_enum_SAPI_HEADER_ADD, header)
45}
46
47/// Removes the response headers called `name`, like `header_remove($name)`.
48///
49/// # Errors
50///
51/// See [`set_header`]. PHP emits a warning when `name` contains a colon.
52pub fn remove_header(name: &str) -> Result<()> {
53    line_op(sapi_header_op_enum_SAPI_HEADER_DELETE, name)
54}
55
56/// Removes all the response headers, like `header_remove()`.
57///
58/// # Errors
59///
60/// See [`set_header`].
61pub fn remove_all_headers() -> Result<()> {
62    header_op(sapi_header_op_enum_SAPI_HEADER_DELETE_ALL, ptr::null_mut())
63}
64
65/// Sets the response status code, like `http_response_code($code)`.
66///
67/// # Errors
68///
69/// See [`set_header`].
70pub fn set_response_code(code: u16) -> Result<()> {
71    header_op(
72        sapi_header_op_enum_SAPI_HEADER_SET_STATUS,
73        ptr::without_provenance_mut(usize::from(code)),
74    )
75}
76
77fn line_op(op: sapi_header_op_enum, line: &str) -> Result<()> {
78    // SAFETY: SAPI.h requires a zeroed `sapi_header_line`. All its fields are
79    // integers or raw pointers, for which zero is valid.
80    let mut header_line: sapi_header_line = unsafe { mem::zeroed() };
81    header_line.line = line.as_ptr().cast();
82    header_line.line_len = line.len();
83    header_op(op, (&raw mut header_line).cast())
84}
85
86fn header_op(op: sapi_header_op_enum, arg: *mut c_void) -> Result<()> {
87    // SAFETY: the output globals exist from module startup until the process
88    // ends.
89    if !unsafe { ext_php_rs_output_activated() } || sapi_globals_borrowed() {
90        return Err(Error::ResponseHeaderFailed);
91    }
92    // SAFETY: output is active only between `sapi_activate` and
93    // `sapi_deactivate_module`, so the header list is live. No guard is alive,
94    // so no Rust reference sees the engine change the SAPI globals. `arg` is
95    // what `op` reads: a `sapi_header_line` that the engine copies, a status
96    // code or nothing. The engine frees its copy before it emits a warning, and
97    // this frame owns nothing to drop, so a bailout from an error handler
98    // leaks nothing.
99    if unsafe { sapi_header_op(op, arg) } == ZEND_RESULT_CODE_SUCCESS {
100        Ok(())
101    } else {
102        Err(Error::ResponseHeaderFailed)
103    }
104}
105
106fn sapi_globals_borrowed() -> bool {
107    cfg_if::cfg_if! {
108        if #[cfg(php_zts)] {
109            lock::SAPI_GLOBALS_LOCK.with(|lock| lock.is_locked())
110        } else {
111            lock::SAPI_GLOBALS_LOCK.is_locked()
112        }
113    }
114}
115
116#[cfg(all(test, feature = "embed"))]
117mod tests {
118    use super::*;
119    use crate::{embed::Embed, zend::SapiGlobals};
120
121    fn headers() -> String {
122        Embed::eval("implode('|', preg_grep('/^X-Test/', headers_list()));")
123            .ok()
124            .and_then(|list| list.string())
125            .unwrap_or_default()
126    }
127
128    #[test]
129    fn set_header_replaces_same_name() {
130        let headers = Embed::run(|| {
131            set_header("X-Test: a").expect("set_header failed");
132            set_header("X-Test: b").expect("set_header failed");
133            headers()
134        });
135
136        assert_eq!(headers, "X-Test: b");
137    }
138
139    #[test]
140    fn add_header_keeps_same_name() {
141        let headers = Embed::run(|| {
142            add_header("X-Test: a").expect("add_header failed");
143            add_header("X-Test: b").expect("add_header failed");
144            headers()
145        });
146
147        assert_eq!(headers, "X-Test: a|X-Test: b");
148    }
149
150    #[test]
151    fn remove_header_drops_name() {
152        let headers = Embed::run(|| {
153            set_header("X-Test-Keep: a").expect("set_header failed");
154            set_header("X-Test-Drop: b").expect("set_header failed");
155            remove_header("X-Test-Drop").expect("remove_header failed");
156            headers()
157        });
158
159        assert_eq!(headers, "X-Test-Keep: a");
160    }
161
162    #[test]
163    fn remove_all_headers_empties_list() {
164        let headers = Embed::run(|| {
165            set_header("X-Test: a").expect("set_header failed");
166            remove_all_headers().expect("remove_all_headers failed");
167            headers()
168        });
169
170        assert_eq!(headers, "");
171    }
172
173    #[test]
174    fn set_response_code_sets_status() {
175        let code = Embed::run(|| {
176            set_response_code(404).expect("set_response_code failed");
177            Embed::eval("http_response_code();")
178                .ok()
179                .and_then(|code| code.long())
180        });
181
182        assert_eq!(code, Some(404));
183    }
184
185    #[test]
186    fn header_with_new_line_is_refused() {
187        let refused = Embed::run(|| {
188            matches!(
189                set_header("X-Test: a\r\nX-Injected: b"),
190                Err(Error::ResponseHeaderFailed)
191            )
192        });
193
194        assert!(refused);
195    }
196
197    #[test]
198    fn empty_header_is_refused() {
199        let refused = Embed::run(|| matches!(set_header(""), Err(Error::ResponseHeaderFailed)));
200
201        assert!(refused);
202    }
203
204    #[test]
205    fn alive_sapi_globals_guard_refuses_change() {
206        let refused = Embed::run(|| {
207            let _guard = SapiGlobals::get();
208            matches!(set_header("X-Test: a"), Err(Error::ResponseHeaderFailed))
209        });
210
211        assert!(refused);
212    }
213}