This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 17:09:41 +02:00
1 parent 3e33982cd3
commit ad95bfb050
27 files changed
+309 -80

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ use super::{
use anyhow::{ensure, Context, Result};
use axum::{body::Body, response::Response};
use bytes::Bytes;
use futures_util::stream;
use futures_util::{stream, StreamExt};
use http::{
header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE},
HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri,
+13 -3
View File
@@ -178,9 +178,7 @@ async fn php_response_inner(
permit,
));
let mut response = Response::new(if send_body {
Body::from_stream(stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
}))
Body::from_stream(fastcgi_body_stream(rx))
} else {
Body::empty()
});
@@ -189,6 +187,18 @@ async fn php_response_inner(
response
}
// tower-http's compression encoder may poll a body again after observing EOF.
// An unfused `stream::unfold` panics on that second poll, aborting the response
// before the encoder can emit its final gzip/br/zstd bytes.
pub(super) fn fastcgi_body_stream(
rx: mpsc::Receiver<io::Result<Bytes>>,
) -> impl futures_util::Stream<Item = io::Result<Bytes>> {
stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
})
.fuse()
}
pub(super) fn method_not_allowed_response() -> Response {
let mut response = status_response(
StatusCode::METHOD_NOT_ALLOWED,
+5 -3
View File
@@ -1,9 +1,11 @@
use super::*;
use super::fastcgi::{
fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, FCGI_END_REQUEST,
FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT,
fastcgi_body_stream, fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record,
FCGI_END_REQUEST, FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT,
};
use axum::{body::Body, response::Response};
use bytes::Bytes;
use futures_util::StreamExt;
use http::{
header::{
ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
@@ -15,7 +17,7 @@ use http::{
use http_body_util::BodyExt;
use std::{collections::BTreeMap, path::Path, time::Duration};
use tempfile::TempDir;
use tokio::{io::AsyncWriteExt, time::Instant};
use tokio::{io::AsyncWriteExt, sync::mpsc, time::Instant};
fn service(root: &Path, listing: bool) -> CompiledService {
Service {
+60
View File
@@ -20,6 +20,66 @@ async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr {
address
}
#[tokio::test]
async fn fastcgi_stream_can_be_polled_repeatedly_after_eof() {
let (tx, rx) = mpsc::channel(1);
tx.send(Ok(Bytes::from_static(b"data"))).await.unwrap();
drop(tx);
let mut body = Box::pin(fastcgi_body_stream(rx));
assert_eq!(body.next().await.unwrap().unwrap(), Bytes::from_static(b"data"));
assert!(body.next().await.is_none());
// Without `fuse` this panics inside futures-util::stream::unfold.
assert!(body.next().await.is_none());
}
#[tokio::test]
async fn php_fpm_gzip_response_finishes_after_fastcgi_eof() {
use std::io::Read;
let expected = b"FastCGI compressed response regression: 0123456789 0123456789 0123456789";
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'placeholder';").unwrap();
let address = fastcgi_fixture(expected).await;
let configured = Service {
id: "php-compression-regression".into(),
name: "PHP compression regression".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.php".into()],
php: Php {
enabled: true,
endpoint: format!("tcp:{address}"),
connect_timeout_ms: 500,
request_timeout_ms: 5_000,
max_concurrent_requests: 1,
..Default::default()
},
..Default::default()
};
configured.validate().unwrap();
let service = configured.compile().unwrap();
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
assert_eq!(response.status(), StatusCode::OK);
let mut accept = HeaderMap::new();
accept.insert("accept-encoding", "gzip".parse().unwrap());
let settings = crate::compression::Settings {
gzip: true,
br: false,
zstd: false,
deflate: false,
min_bytes: 32,
};
let response = crate::compression::encode(response, &accept, &Method::GET, &settings).await;
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
let compressed = response.into_body().collect().await.unwrap().to_bytes();
let mut decoded = Vec::new();
flate2::read::GzDecoder::new(compressed.as_ref())
.read_to_end(&mut decoded)
.unwrap();
assert_eq!(decoded.as_slice(), expected);
}
#[tokio::test]
async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() {
let tmp = TempDir::new().unwrap();