Skip to main content

rws_proxy/fastcgi/
mod.rs

1//! FastCGI (18.21.0, #72): PHP through php-fpm, and any other FastCGI
2//! responder, without FrankenPHP or a second web server.
3//!
4//! [`FastCgi`] is an [`Application`]: it maps a request onto a script under
5//! its root the way nginx's and Caddy's PHP setups do, sends it to the
6//! FastCGI server over TCP or a Unix socket, and turns the CGI response
7//! (RFC 3875 §6) back into a [`Response`]. Connections are kept alive and
8//! pooled. The config file's `type = "fastcgi"` action builds one.
9//!
10//! Which script runs for a request path `P` (with `split_path = ".php"`):
11//!
12//! 1. `P` names a file under the root that isn't a script → served as a
13//!    static file (when a static handler is attached).
14//! 2. `P` is a directory with an index script (`index.php`) → that script
15//!    (`/dir` without the slash gets a redirect to `/dir/`).
16//! 3. `P` contains `.php` and the part up to it is a script file → that
17//!    script, the rest is `PATH_INFO` (`/index.php/users/7`).
18//! 4. Otherwise the front controller (`/index.php`, the Laravel / Symfony /
19//!    WordPress pattern), with `REQUEST_URI` unchanged.
20//!
21//! A script must be a regular file under the root whose path ends in the
22//! split extension and has no dot-file segment, so `/uploads/x.jpg/a.php`
23//! never runs `x.jpg` (the old `cgi.fix_pathinfo` hole).
24
25use std::collections::HashMap;
26use std::io::{Read, Write};
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, Mutex};
29use std::time::Duration;
30
31use crate::application::Application;
32use crate::core::New;
33use crate::header::Header;
34use crate::range::Range;
35use crate::request::Request;
36use crate::response::Response;
37use crate::server::ConnectionInfo;
38
39// ── Records (FastCGI 1.0 spec, §3, §8) ──────────────────────────────────────
40
41#[doc(hidden)]
42pub const VERSION: u8 = 1;
43#[doc(hidden)]
44pub const BEGIN_REQUEST: u8 = 1;
45#[doc(hidden)]
46pub const END_REQUEST: u8 = 3;
47#[doc(hidden)]
48pub const PARAMS: u8 = 4;
49#[doc(hidden)]
50pub const STDIN: u8 = 5;
51#[doc(hidden)]
52pub const STDOUT: u8 = 6;
53#[doc(hidden)]
54pub const STDERR: u8 = 7;
55const RESPONDER: u16 = 1;
56const KEEP_CONN: u8 = 1;
57const MAX_CONTENT: usize = 65535;
58
59/// One record: type, request id and content.
60#[derive(Debug, Clone, PartialEq)]
61pub struct Record {
62    pub kind: u8,
63    pub request_id: u16,
64    pub content: Vec<u8>,
65}
66
67/// Append a record (content ≤ 65535 bytes) to `out`, padded to 8 bytes.
68pub fn encode_record(out: &mut Vec<u8>, kind: u8, request_id: u16, content: &[u8]) {
69    debug_assert!(content.len() <= MAX_CONTENT);
70    let padding = (8 - content.len() % 8) % 8;
71    out.extend_from_slice(&[VERSION, kind]);
72    out.extend_from_slice(&request_id.to_be_bytes());
73    out.extend_from_slice(&(content.len() as u16).to_be_bytes());
74    out.extend_from_slice(&[padding as u8, 0]);
75    out.extend_from_slice(content);
76    out.extend(std::iter::repeat_n(0u8, padding));
77}
78
79/// Append a stream (PARAMS, STDIN) as records, then its empty terminator.
80pub fn encode_stream(out: &mut Vec<u8>, kind: u8, request_id: u16, data: &[u8]) {
81    for chunk in data.chunks(MAX_CONTENT) {
82        encode_record(out, kind, request_id, chunk);
83    }
84    encode_record(out, kind, request_id, &[]);
85}
86
87/// Name-value pairs (§3.4): lengths < 128 in one byte, others in four with
88/// the top bit set.
89pub fn encode_params(params: &[(String, String)]) -> Vec<u8> {
90    let mut out = Vec::new();
91    let length = |out: &mut Vec<u8>, n: usize| {
92        if n < 128 {
93            out.push(n as u8);
94        } else {
95            out.extend_from_slice(&((n as u32) | 0x8000_0000).to_be_bytes());
96        }
97    };
98    for (name, value) in params {
99        length(&mut out, name.len());
100        length(&mut out, value.len());
101        out.extend_from_slice(name.as_bytes());
102        out.extend_from_slice(value.as_bytes());
103    }
104    out
105}
106
107/// Decode name-value pairs; `None` on truncated or oversized input.
108pub fn decode_params(mut data: &[u8]) -> Option<Vec<(String, String)>> {
109    fn length(data: &mut &[u8]) -> Option<usize> {
110        let first = *data.first()?;
111        if first < 128 {
112            *data = &data[1..];
113            return Some(first as usize);
114        }
115        let bytes: [u8; 4] = data.get(..4)?.try_into().ok()?;
116        *data = &data[4..];
117        Some((u32::from_be_bytes(bytes) & 0x7fff_ffff) as usize)
118    }
119    let mut out = Vec::new();
120    while !data.is_empty() {
121        let (n, v) = (length(&mut data)?, length(&mut data)?);
122        if data.len() < n.checked_add(v)? {
123            return None;
124        }
125        out.push((String::from_utf8_lossy(&data[..n]).into_owned(), String::from_utf8_lossy(&data[n..n + v]).into_owned()));
126        data = &data[n + v..];
127    }
128    Some(out)
129}
130
131/// Read one record; `Ok(None)` at a clean end of stream.
132pub fn read_record(reader: &mut impl Read) -> std::io::Result<Option<Record>> {
133    let mut header = [0u8; 8];
134    match reader.read(&mut header[..1])? {
135        0 => return Ok(None),
136        _ => reader.read_exact(&mut header[1..])?,
137    }
138    if header[0] != VERSION {
139        return Err(std::io::Error::other(format!("FastCGI: unsupported record version {}", header[0])));
140    }
141    let length = u16::from_be_bytes([header[4], header[5]]) as usize;
142    let mut content = vec![0u8; length + header[6] as usize];
143    reader.read_exact(&mut content)?;
144    content.truncate(length);
145    Ok(Some(Record { kind: header[1], request_id: u16::from_be_bytes([header[2], header[3]]), content }))
146}
147
148/// Parse a buffer of records completely (for the fuzzer and tests):
149/// `None` on a malformed record.
150pub fn parse_records(mut data: &[u8]) -> Option<Vec<Record>> {
151    let mut out = Vec::new();
152    while !data.is_empty() {
153        out.push(read_record(&mut data).ok()??);
154    }
155    Some(out)
156}
157
158// ── CGI response (RFC 3875 §6) ──────────────────────────────────────────────
159
160/// The end of the CGI header block in `data` (index after the blank line).
161fn header_end(data: &[u8]) -> Option<usize> {
162    let crlf = data.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4);
163    let lf = data.windows(2).position(|w| w == b"\n\n").map(|i| i + 2);
164    match (crlf, lf) {
165        (Some(a), Some(b)) => Some(a.min(b)),
166        (a, b) => a.or(b),
167    }
168}
169
170/// Status, reason and headers of a CGI response header block. `Status:`
171/// sets the status; a `Location:` without one is a `302`.
172pub fn parse_cgi_headers(block: &[u8]) -> Result<(u16, String, Vec<(String, String)>), String> {
173    let text = String::from_utf8_lossy(block);
174    let mut status: Option<(u16, String)> = None;
175    let mut headers = Vec::new();
176    for line in text.lines() {
177        let line = line.trim_end_matches('\r');
178        if line.is_empty() {
179            continue;
180        }
181        let Some((name, value)) = line.split_once(':') else {
182            return Err(format!("FastCGI: bad header line {line:?}"));
183        };
184        let (name, value) = (name.trim(), value.trim());
185        if name.is_empty() || name.bytes().any(|b| !(b.is_ascii_graphic()) || b == b':') || value.contains(['\r', '\n']) {
186            return Err(format!("FastCGI: bad header line {line:?}"));
187        }
188        if name.eq_ignore_ascii_case("status") {
189            let (code, reason) = value.split_once(' ').unwrap_or((value, ""));
190            let code: u16 = code.parse().ok().filter(|c| (100..1000).contains(c)).ok_or_else(|| format!("FastCGI: bad Status {value:?}"))?;
191            status = Some((code, reason.trim().to_string()));
192        } else {
193            headers.push((name.to_string(), value.to_string()));
194        }
195    }
196    let (code, reason) = match status {
197        Some(s) => s,
198        None if headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("location")) => (302, String::new()),
199        None => (200, String::new()),
200    };
201    Ok((code, reason, headers))
202}
203
204fn reason_for(code: u16) -> String {
205    Response::status_code_reason_phrase_list()
206        .iter()
207        .find(|s| *s.status_code == code as i16)
208        .map(|s| s.reason_phrase.to_string())
209        .unwrap_or_else(|| if code < 400 { "OK".to_string() } else { "Error".to_string() })
210}
211
212// ── Connections ─────────────────────────────────────────────────────────────
213
214/// Where the FastCGI server listens.
215#[derive(Debug, Clone, PartialEq)]
216pub enum Address {
217    Tcp(String),
218    #[cfg(unix)]
219    Unix(PathBuf),
220}
221
222impl Address {
223    /// `unix:/run/php/php-fpm.sock`, `/run/php/php-fpm.sock`, `tcp://host:port`
224    /// or `host:port`.
225    pub fn parse(address: &str) -> Result<Address, String> {
226        let address = address.trim();
227        if let Some(path) = address.strip_prefix("unix:").or_else(|| address.starts_with('/').then_some(address)) {
228            #[cfg(unix)]
229            return Ok(Address::Unix(PathBuf::from(path)));
230            #[cfg(not(unix))]
231            return Err(format!("FastCGI: Unix sockets aren't available here ({path})"));
232        }
233        let hostport = address.strip_prefix("tcp://").unwrap_or(address);
234        if hostport.rsplit_once(':').is_some_and(|(h, p)| !h.is_empty() && p.parse::<u16>().is_ok()) {
235            Ok(Address::Tcp(hostport.to_string()))
236        } else {
237            Err(format!("FastCGI: address {address:?}: expected unix:/path or host:port"))
238        }
239    }
240}
241
242trait Stream: Read + Write + Send {}
243impl<T: Read + Write + Send> Stream for T {}
244
245struct Conn {
246    stream: Box<dyn Stream>,
247}
248
249impl Conn {
250    fn open(address: &Address, connect: Duration, io: Duration) -> std::io::Result<Conn> {
251        match address {
252            Address::Tcp(hostport) => {
253                let mut last = std::io::Error::other(format!("no address for {hostport}"));
254                for addr in std::net::ToSocketAddrs::to_socket_addrs(hostport.as_str())? {
255                    match std::net::TcpStream::connect_timeout(&addr, connect) {
256                        Ok(s) => {
257                            s.set_read_timeout(Some(io))?;
258                            s.set_write_timeout(Some(io))?;
259                            let _ = s.set_nodelay(true);
260                            return Ok(Conn { stream: Box::new(s) });
261                        }
262                        Err(e) => last = e,
263                    }
264                }
265                Err(last)
266            }
267            #[cfg(unix)]
268            Address::Unix(path) => {
269                let s = std::os::unix::net::UnixStream::connect(path)?;
270                s.set_read_timeout(Some(io))?;
271                s.set_write_timeout(Some(io))?;
272                Ok(Conn { stream: Box::new(s) })
273            }
274        }
275    }
276}
277
278/// Idle kept-alive connections, at most `max` per FastCGI server.
279struct Pool {
280    idle: Mutex<Vec<Conn>>,
281    max: usize,
282}
283
284// ── The application ─────────────────────────────────────────────────────────
285
286/// Settings of a [`FastCgi`] handler.
287#[derive(Debug, Clone)]
288pub struct FastCgiConfig {
289    /// The FastCGI server.
290    pub address: String,
291    /// Document root: scripts and static files live under it.
292    pub root: PathBuf,
293    /// Index scripts of a directory (default `index.php`).
294    pub index: Vec<String>,
295    /// The script extension that splits `PATH_INFO` off (default `.php`).
296    pub split_path: String,
297    /// Script for paths that match nothing (default `index.php`; empty: 404).
298    pub front_controller: String,
299    /// Extra CGI variables (`APP_ENV = "prod"`).
300    pub params: Vec<(String, String)>,
301    pub connect_timeout: Duration,
302    /// Per read/write on the connection (a slow script waits this long).
303    pub read_timeout: Duration,
304    /// Idle connections kept per server.
305    pub max_idle: usize,
306}
307
308impl FastCgiConfig {
309    pub fn new(address: impl Into<String>, root: impl Into<PathBuf>) -> FastCgiConfig {
310        FastCgiConfig {
311            address: address.into(),
312            root: root.into(),
313            index: vec!["index.php".to_string()],
314            split_path: ".php".to_string(),
315            front_controller: "index.php".to_string(),
316            params: Vec::new(),
317            connect_timeout: Duration::from_secs(5),
318            read_timeout: Duration::from_secs(60),
319            max_idle: 16,
320        }
321    }
322}
323
324/// FastCGI as an [`Application`]. See the [module docs](self).
325#[derive(Clone)]
326pub struct FastCgi {
327    inner: Arc<Inner>,
328}
329
330struct Inner {
331    config: FastCgiConfig,
332    address: Address,
333    /// The root with symlinks resolved: what a file must be under.
334    root: PathBuf,
335    /// The root as configured (made absolute, symlinks kept): what php-fpm
336    /// is told. In another container the same files sit at the same paths,
337    /// but symlink targets may differ (a Kubernetes ConfigMap volume's
338    /// `..<timestamp>` directory), so resolved paths aren't found there.
339    lexical_root: PathBuf,
340    pool: Pool,
341    statics: Option<Arc<dyn Application + Send + Sync>>,
342}
343
344/// What a request path maps to.
345#[derive(Debug, PartialEq)]
346#[doc(hidden)]
347pub enum Target {
348    /// A script: `SCRIPT_NAME`, its file, `PATH_INFO`.
349    Script { script_name: String, filename: PathBuf, path_info: String },
350    /// A file that isn't a script.
351    Static,
352    /// A directory without its trailing slash: redirect here.
353    Redirect(String),
354    NotFound,
355}
356
357impl FastCgi {
358    /// A handler for `config`; `Err` for a bad address or a missing root.
359    pub fn new(config: FastCgiConfig) -> Result<FastCgi, String> {
360        let address = Address::parse(&config.address)?;
361        let root = config.root.canonicalize().map_err(|e| format!("FastCGI root {}: {e}", config.root.display()))?;
362        let lexical_root = std::path::absolute(&config.root).map_err(|e| format!("FastCGI root {}: {e}", config.root.display()))?;
363        let max = config.max_idle;
364        Ok(FastCgi { inner: Arc::new(Inner { config, address, root, lexical_root, pool: Pool { idle: Mutex::new(Vec::new()), max }, statics: None }) })
365    }
366
367    /// Serve files under the root that aren't scripts with `app`.
368    pub fn with_static(self, app: Arc<dyn Application + Send + Sync>) -> FastCgi {
369        let inner = &self.inner;
370        FastCgi {
371            inner: Arc::new(Inner {
372                config: inner.config.clone(),
373                address: inner.address.clone(),
374                root: inner.root.clone(),
375                lexical_root: inner.lexical_root.clone(),
376                pool: Pool { idle: Mutex::new(Vec::new()), max: inner.pool.max },
377                statics: Some(app),
378            }),
379        }
380    }
381
382    fn is_script(&self, name: &str) -> bool {
383        !self.inner.config.split_path.is_empty() && name.ends_with(&self.inner.config.split_path)
384    }
385
386    /// A regular file under the root (checked with symlinks resolved), as
387    /// the configured root joined with `relative` — the path nginx's
388    /// `$document_root$fastcgi_script_name` gives — or `None`.
389    fn file_under_root(&self, relative: &str) -> Option<PathBuf> {
390        let relative = relative.trim_start_matches('/');
391        let real = self.inner.root.join(relative).canonicalize().ok()?;
392        (real.starts_with(&self.inner.root) && real.is_file()).then(|| self.inner.lexical_root.join(relative))
393    }
394
395    #[doc(hidden)]
396    pub fn resolve(&self, uri_path: &str) -> Target {
397        let decoded = crate::url::URL::percent_decode(uri_path);
398        let segments: Vec<&str> = decoded.split('/').collect();
399        // Dot-files (`.env`, `.git/`) are never served or run; `.well-known/`
400        // (ACME, security.txt, app links) is (18.24.0).
401        if segments.iter().any(|s| *s == ".." || (s.starts_with('.') && *s != ".well-known")) || decoded.contains('\0') {
402            return Target::NotFound;
403        }
404        let path = if decoded.starts_with('/') { decoded.clone() } else { format!("/{decoded}") };
405        let full = self.inner.root.join(path.trim_start_matches('/'));
406        // 1. A file.
407        if let Some(file) = self.file_under_root(&path) {
408            if self.is_script(&path) {
409                return Target::Script { script_name: path, filename: file, path_info: String::new() };
410            }
411            return Target::Static;
412        }
413        // 2. A directory with an index script.
414        if full.is_dir() && full.canonicalize().is_ok_and(|r| r.starts_with(&self.inner.root)) {
415            for index in &self.inner.config.index {
416                let script = format!("{}/{}", path.trim_end_matches('/'), index);
417                if let Some(file) = self.file_under_root(&script) {
418                    if !path.ends_with('/') {
419                        return Target::Redirect(format!("{path}/"));
420                    }
421                    if self.is_script(&script) {
422                        return Target::Script { script_name: script, filename: file, path_info: String::new() };
423                    }
424                    return Target::Static;
425                }
426            }
427        }
428        // 3. /script.php/path/info
429        let split = &self.inner.config.split_path;
430        if !split.is_empty() {
431            let mut from = 0;
432            while let Some(found) = path[from..].find(split.as_str()) {
433                let end = from + found + split.len();
434                if path[end..].starts_with('/') {
435                    let script = &path[..end];
436                    if let Some(file) = self.file_under_root(script) {
437                        return Target::Script { script_name: script.to_string(), filename: file, path_info: path[end..].to_string() };
438                    }
439                }
440                from = end;
441            }
442        }
443        // 4. The front controller.
444        let front = &self.inner.config.front_controller;
445        if !front.is_empty() {
446            let script = format!("/{}", front.trim_start_matches('/'));
447            if let Some(file) = self.file_under_root(&script) {
448                return Target::Script { script_name: script, filename: file, path_info: String::new() };
449            }
450        }
451        Target::NotFound
452    }
453
454    /// The CGI/1.1 variables for `request` running `script_name`.
455    #[doc(hidden)]
456    pub fn params(&self, request: &Request, connection: &ConnectionInfo, script_name: &str, filename: &Path, path_info: &str) -> Vec<(String, String)> {
457        let (_, query) = request.request_uri.split_once('?').unwrap_or((&request.request_uri, ""));
458        let host = request.header("Host").map(|h| h.value.to_string()).unwrap_or_default();
459        let server_name = match host.rsplit_once(':') {
460            Some((h, p)) if p.bytes().all(|b| b.is_ascii_digit()) && !h.ends_with(':') => h.to_string(),
461            _ => host.clone(),
462        };
463        let root = self.inner.lexical_root.to_string_lossy().trim_end_matches('/').to_string();
464        let mut params: Vec<(String, String)> = vec![
465            ("GATEWAY_INTERFACE".into(), "CGI/1.1".into()),
466            ("SERVER_SOFTWARE".into(), format!("rws/{}", env!("CARGO_PKG_VERSION"))),
467            ("SERVER_PROTOCOL".into(), "HTTP/1.1".into()),
468            ("REQUEST_METHOD".into(), request.method.clone()),
469            ("REQUEST_URI".into(), request.request_uri.clone()),
470            ("QUERY_STRING".into(), query.to_string()),
471            ("SCRIPT_NAME".into(), script_name.to_string()),
472            ("SCRIPT_FILENAME".into(), filename.to_string_lossy().to_string()),
473            ("DOCUMENT_ROOT".into(), root.clone()),
474            ("DOCUMENT_URI".into(), format!("{script_name}{path_info}")),
475            ("PATH_INFO".into(), path_info.to_string()),
476            ("REMOTE_ADDR".into(), crate::ip_filter::client_ip(request, &connection.client.ip).into_owned()),
477            ("REMOTE_PORT".into(), connection.client.port.to_string()),
478            ("SERVER_ADDR".into(), connection.server.ip.clone()),
479            ("SERVER_PORT".into(), connection.server.port.to_string()),
480            ("SERVER_NAME".into(), server_name),
481            ("REQUEST_SCHEME".into(), if connection.tls { "https" } else { "http" }.into()),
482            // php-cgi built with force-cgi-redirect wants it.
483            ("REDIRECT_STATUS".into(), "200".into()),
484        ];
485        if !path_info.is_empty() {
486            params.push(("PATH_TRANSLATED".into(), format!("{root}{path_info}")));
487        }
488        if connection.tls {
489            params.push(("HTTPS".into(), "on".into()));
490        }
491        params.push(("CONTENT_LENGTH".into(), if request.body.is_empty() { String::new() } else { request.body.len().to_string() }));
492        params.push(("CONTENT_TYPE".into(), request.header("Content-Type").map(|h| h.value.to_string()).unwrap_or_default()));
493        for header in &request.headers {
494            let name = header.name.to_ascii_uppercase().replace('-', "_");
495            // httpoxy: a client's `Proxy:` header must never become HTTP_PROXY.
496            if name == "PROXY" || name == "CONTENT_TYPE" || name == "CONTENT_LENGTH" || name.bytes().any(|b| !(b.is_ascii_alphanumeric() || b == b'_')) {
497                continue;
498            }
499            let key = format!("HTTP_{name}");
500            match params.iter_mut().find(|(k, _)| *k == key) {
501                Some((_, v)) => {
502                    v.push_str(if name == "COOKIE" { "; " } else { ", " });
503                    v.push_str(&header.value);
504                }
505                None => params.push((key, header.value.to_string())),
506            }
507        }
508        for (k, v) in &self.inner.config.params {
509            match params.iter_mut().find(|(name, _)| name == k) {
510                Some((_, value)) => *value = v.clone(),
511                None => params.push((k.clone(), v.clone())),
512            }
513        }
514        params
515    }
516
517    fn take_conn(&self) -> Option<Conn> {
518        self.inner.pool.idle.lock().unwrap_or_else(|e| e.into_inner()).pop()
519    }
520
521    fn put_conn(&self, conn: Conn) {
522        let mut idle = self.inner.pool.idle.lock().unwrap_or_else(|e| e.into_inner());
523        if idle.len() < self.inner.pool.max {
524            idle.push(conn);
525        }
526    }
527
528    /// Run the script: the CGI output split into header block and body.
529    fn exchange(&self, params: &[(String, String)], body: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
530        let mut message = Vec::with_capacity(body.len() + 1024);
531        let mut begin = Vec::with_capacity(8);
532        begin.extend_from_slice(&RESPONDER.to_be_bytes());
533        begin.extend_from_slice(&[KEEP_CONN, 0, 0, 0, 0, 0]);
534        encode_record(&mut message, BEGIN_REQUEST, 1, &begin);
535        encode_stream(&mut message, PARAMS, 1, &encode_params(params));
536        encode_stream(&mut message, STDIN, 1, body);
537        let cfg = &self.inner.config;
538        // A pooled connection may have been closed by the server: retry once
539        // on a fresh one when nothing came back.
540        let mut tried_fresh = false;
541        loop {
542            let (mut conn, pooled) = match self.take_conn() {
543                Some(c) if !tried_fresh => (c, true),
544                _ => (Conn::open(&self.inner.address, cfg.connect_timeout, cfg.read_timeout).map_err(|e| format!("FastCGI server {}: {e}", cfg.address))?, false),
545            };
546            match self.round_trip(&mut conn, &message) {
547                Ok((out, keep)) => {
548                    if keep {
549                        self.put_conn(conn);
550                    }
551                    let end = header_end(&out).ok_or_else(|| "FastCGI: the response has no header block".to_string())?;
552                    let mut out = out;
553                    let body = out.split_off(end);
554                    return Ok((out, body));
555                }
556                Err((e, nothing_read)) if pooled && nothing_read => {
557                    let _ = e;
558                    tried_fresh = true;
559                }
560                Err((e, _)) => return Err(format!("FastCGI server {}: {e}", cfg.address)),
561            }
562        }
563    }
564
565    /// Send `message`, read STDOUT until END_REQUEST. `Err((error, nothing
566    /// was read))`; `Ok((stdout, connection reusable))`.
567    fn round_trip(&self, conn: &mut Conn, message: &[u8]) -> Result<(Vec<u8>, bool), (String, bool)> {
568        conn.stream.write_all(message).and_then(|_| conn.stream.flush()).map_err(|e| (e.to_string(), true))?;
569        let mut stdout = Vec::new();
570        let mut stderr = Vec::new();
571        let mut read_any = false;
572        loop {
573            let record = match read_record(&mut conn.stream) {
574                Ok(Some(r)) => r,
575                Ok(None) => return Err(("the connection closed before the response ended".to_string(), !read_any)),
576                Err(e) => return Err((e.to_string(), !read_any)),
577            };
578            read_any = true;
579            if record.request_id != 1 {
580                continue;
581            }
582            match record.kind {
583                STDOUT => stdout.extend_from_slice(&record.content),
584                STDERR => stderr.extend_from_slice(&record.content),
585                END_REQUEST => {
586                    let text = String::from_utf8_lossy(&stderr);
587                    for line in text.lines().filter(|l| !l.trim().is_empty()) {
588                        eprintln!("[fastcgi] {line}");
589                    }
590                    // protocolStatus 0 = request complete.
591                    let complete = record.content.get(4).is_none_or(|s| *s == 0);
592                    if !complete {
593                        return Err((format!("the server refused the request (protocol status {})", record.content[4]), false));
594                    }
595                    return Ok((stdout, true));
596                }
597                _ => {}
598            }
599        }
600    }
601}
602
603fn simple(status: u16, headers: Vec<Header>, body: &str) -> Response {
604    let mut r = Response::new();
605    r.status_code = status as i16;
606    r.reason_phrase = reason_for(status);
607    r.headers.extend(headers);
608    if !body.is_empty() {
609        r.content_range_list = vec![Range::get_content_range(body.as_bytes().to_vec(), "text/plain".to_string())];
610    }
611    r
612}
613
614impl Application for FastCgi {
615    fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
616        let Some(path) = crate::url::URL::request_target_path(&request.request_uri) else {
617            return Ok(simple(400, Vec::new(), "Bad Request"));
618        };
619        match self.resolve(path) {
620            Target::NotFound => Ok(simple(404, Vec::new(), "Not Found")),
621            Target::Redirect(location) => {
622                let query = request.request_uri.split_once('?').map(|(_, q)| format!("?{q}")).unwrap_or_default();
623                Ok(simple(301, vec![Header::new("Location", format!("{location}{query}"))], ""))
624            }
625            Target::Static => match &self.inner.statics {
626                Some(app) => app.execute(request, connection),
627                None => Ok(simple(404, Vec::new(), "Not Found")),
628            },
629            Target::Script { script_name, filename, path_info } => {
630                let params = self.params(request, connection, &script_name, &filename, &path_info);
631                let (head, body) = match self.exchange(&params, &request.body) {
632                    Ok(out) => out,
633                    Err(e) => {
634                        eprintln!("[fastcgi] {e}");
635                        return Ok(simple(502, Vec::new(), "Bad Gateway"));
636                    }
637                };
638                let (status, reason, headers) = match parse_cgi_headers(&head) {
639                    Ok(h) => h,
640                    Err(e) => {
641                        eprintln!("[fastcgi] {e}");
642                        return Ok(simple(502, Vec::new(), "Bad Gateway"));
643                    }
644                };
645                let mut response = Response::new();
646                response.status_code = status as i16;
647                response.reason_phrase = if reason.is_empty() { reason_for(status) } else { reason };
648                let mut content_type = String::new();
649                for (name, value) in headers {
650                    if name.eq_ignore_ascii_case("content-type") {
651                        content_type = value;
652                    } else if !name.eq_ignore_ascii_case("content-length") && !name.eq_ignore_ascii_case("transfer-encoding") && !name.eq_ignore_ascii_case("connection") {
653                        response.headers.push(Header::new(name, value));
654                    }
655                }
656                if !body.is_empty() || !content_type.is_empty() {
657                    let content_type = if content_type.is_empty() { "application/octet-stream".to_string() } else { content_type };
658                    response.content_range_list = vec![Range::get_content_range(body, content_type)];
659                }
660                Ok(response)
661            }
662        }
663    }
664}
665
666/// Parse the FastCGI routes' `params` table into pairs (for the config file).
667pub fn params_from_map(map: &HashMap<String, String>) -> Vec<(String, String)> {
668    let mut v: Vec<(String, String)> = map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
669    v.sort();
670    v
671}
672
673#[doc(hidden)]
674pub mod fuzz {
675    /// Fuzz entry: records, params and a CGI header block from bytes.
676    pub fn records(data: &[u8]) {
677        if let Some(records) = super::parse_records(data) {
678            for r in records {
679                if r.kind == super::PARAMS {
680                    let _ = super::decode_params(&r.content);
681                }
682            }
683        }
684        let _ = super::decode_params(data);
685        if let Some(end) = super::header_end(data) {
686            let _ = super::parse_cgi_headers(&data[..end]);
687        }
688    }
689}
690
691#[cfg(test)]
692mod tests;