From 12cd58349a88ea4d9d27427e1b2f4b41de483272 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Sun, 23 Aug 2026 18:01:32 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(middleware):=20response-phase=20module?= =?UTF-8?q?s=20=E2=80=94=20request-id,=20header-transform,=20compression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three native middleware modules that use the response-phase ABI from ephpm/ephpm #408 (pinned at main e6328483), plus the two cdylib shells and release wiring for each. - request-id (request + response phase): generate or honor an inbound X-Request-Id, inject it for PHP, and echo it on the response. Trusted inbound ids are validated (printable ASCII, bounded length) to block header injection. The request phase carries the id to the response itself because the v1 response phase cannot see request-phase state; the response phase fills the header in only when absent (e.g. the static-file path). - header-transform (request + response phase): set request headers PHP sees; set/remove response headers out. Request-side remove and duplicate-append are rejected at init rather than silently ignored — the v1 ABI supports neither. - compression (response phase only): gzip/brotli the buffered body with Accept-Encoding negotiation, Vary, host-recomputed Content-Length. Compression overlap: ePHPm core already compresses buffered responses by default (brotli-then-gzip, before the response phase). This module skips any response that already carries a Content-Encoding, so it is inert on a stock server and never double-encodes — mount it only when core compression is off. The overlap is documented in the module docs and the README. Bumps the ephpm-middleware / ephpm-kv git pin to e6328483 (the #408 merge) for the ResponseMiddleware trait, declare!(Type, response) arm, and ResponseView accessors. 114 module unit tests, clippy, and fmt all clean. --- .github/workflows/release.yml | 5 +- Cargo.lock | 32 +- Cargo.toml | 16 +- README.md | 30 +- .../ephpm-middleware-compression/Cargo.toml | 21 + .../ephpm-middleware-compression/src/lib.rs | 15 + .../Cargo.toml | 21 + .../src/lib.rs | 11 + crates/ephpm-middleware-modules/Cargo.toml | 3 + .../src/compression.rs | 557 ++++++++++++++++++ .../src/header_transform.rs | 302 ++++++++++ crates/ephpm-middleware-modules/src/lib.rs | 3 + .../src/request_id.rs | 388 ++++++++++++ crates/ephpm-middleware-request-id/Cargo.toml | 21 + crates/ephpm-middleware-request-id/src/lib.rs | 11 + 15 files changed, 1427 insertions(+), 9 deletions(-) create mode 100644 crates/ephpm-middleware-compression/Cargo.toml create mode 100644 crates/ephpm-middleware-compression/src/lib.rs create mode 100644 crates/ephpm-middleware-header-transform/Cargo.toml create mode 100644 crates/ephpm-middleware-header-transform/src/lib.rs create mode 100644 crates/ephpm-middleware-modules/src/compression.rs create mode 100644 crates/ephpm-middleware-modules/src/header_transform.rs create mode 100644 crates/ephpm-middleware-modules/src/request_id.rs create mode 100644 crates/ephpm-middleware-request-id/Cargo.toml create mode 100644 crates/ephpm-middleware-request-id/src/lib.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7edf53..9018b4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,10 @@ jobs: {"short":"redirect", "crate":"ephpm-middleware-redirect"}, {"short":"security-headers", "crate":"ephpm-middleware-security-headers"}, {"short":"maintenance-mode", "crate":"ephpm-middleware-maintenance-mode"}, - {"short":"ip-allowlist", "crate":"ephpm-middleware-ip-allowlist"} + {"short":"ip-allowlist", "crate":"ephpm-middleware-ip-allowlist"}, + {"short":"request-id", "crate":"ephpm-middleware-request-id"}, + {"short":"header-transform", "crate":"ephpm-middleware-header-transform"}, + {"short":"compression", "crate":"ephpm-middleware-compression"} ]' if [ "$ONLY_HOST" = "true" ]; then diff --git a/Cargo.lock b/Cargo.lock index 82ea37c..2143753 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "ephpm-config" version = "0.1.0" -source = "git+https://github.com/ephpm/ephpm.git?rev=a6e5317718dfa11ed2b7f6970996139b305eed73#a6e5317718dfa11ed2b7f6970996139b305eed73" +source = "git+https://github.com/ephpm/ephpm.git?rev=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" dependencies = [ "figment", "serde", @@ -184,7 +184,7 @@ dependencies = [ [[package]] name = "ephpm-kv" version = "0.1.0" -source = "git+https://github.com/ephpm/ephpm.git?rev=a6e5317718dfa11ed2b7f6970996139b305eed73#a6e5317718dfa11ed2b7f6970996139b305eed73" +source = "git+https://github.com/ephpm/ephpm.git?rev=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" dependencies = [ "anyhow", "brotli", @@ -205,7 +205,7 @@ dependencies = [ [[package]] name = "ephpm-middleware" version = "0.1.0" -source = "git+https://github.com/ephpm/ephpm.git?rev=a6e5317718dfa11ed2b7f6970996139b305eed73#a6e5317718dfa11ed2b7f6970996139b305eed73" +source = "git+https://github.com/ephpm/ephpm.git?rev=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" dependencies = [ "ephpm-kv", "serde_json", @@ -220,6 +220,14 @@ dependencies = [ "ephpm-middleware-modules", ] +[[package]] +name = "ephpm-middleware-compression" +version = "0.1.0" +dependencies = [ + "ephpm-middleware", + "ephpm-middleware-modules", +] + [[package]] name = "ephpm-middleware-cors" version = "0.1.0" @@ -228,6 +236,14 @@ dependencies = [ "ephpm-middleware-modules", ] +[[package]] +name = "ephpm-middleware-header-transform" +version = "0.1.0" +dependencies = [ + "ephpm-middleware", + "ephpm-middleware-modules", +] + [[package]] name = "ephpm-middleware-ip-allowlist" version = "0.1.0" @@ -259,8 +275,10 @@ name = "ephpm-middleware-modules" version = "0.1.0" dependencies = [ "base64ct", + "brotli", "ephpm-kv", "ephpm-middleware", + "flate2", "hmac", "ipnetwork", "serde_json", @@ -285,6 +303,14 @@ dependencies = [ "ephpm-middleware-modules", ] +[[package]] +name = "ephpm-middleware-request-id" +version = "0.1.0" +dependencies = [ + "ephpm-middleware", + "ephpm-middleware-modules", +] + [[package]] name = "ephpm-middleware-security-headers" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index aa74b10..50b5dc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,14 +19,20 @@ repository = "https://github.com/ephpm/middleware" # by `rev` exactly like ePHPm pins litewire: a drift in `EphpmHostV1` / `ABI_V1` # would be silent UB at the FFI boundary, so the module is provably built # against one specific host-ABI commit. Bump = replace `rev` + `cargo update`. +# +# Pinned at ePHPm main `e63284838d07d348e2155e76916daaf9782c012b` — the merge of +# #408, which added the response-phase ABI hook (`ResponseMiddleware` / +# `declare!(Type, response)` / the `ResponseView` accessors) the request-id, +# header-transform and compression modules build on. Do NOT advance this to +# #409's scheme/host/body accessors: none of these three modules need them. # The rlib of shared module implementations — re-exported by the cdylib shells. ephpm-middleware-modules = { path = "crates/ephpm-middleware-modules" } -ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "a6e5317718dfa11ed2b7f6970996139b305eed73" } +ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } # Test-only: the same embedded KV store the host wires into the middleware host # table, so the ratelimit unit tests exercise the real `kv_incr_ttl` path. Same # rev as the ABI crate so both resolve to one crate instance and the `Store` # type matches `ephpm_middleware::host::set_kv_store`. -ephpm-kv = { git = "https://github.com/ephpm/ephpm.git", rev = "a6e5317718dfa11ed2b7f6970996139b305eed73" } +ephpm-kv = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } serde_json = "1" # ip-allowlist: CIDR parsing + membership for IPv4/IPv6. Default features only @@ -39,6 +45,12 @@ base64ct = { version = "1", features = ["alloc"] } # api-key: constant-time key comparison to close the timing oracle a naive `==` # would open. Tiny, no_std, no transitive deps. subtle = "2" +# compression: gzip + brotli for the response-phase body transform. Same crates +# and majors ePHPm's own buffered-compression path uses (`flate2 = "1"`, +# `brotli = "7"` in the ePHPm workspace), so a module vendored back in resolves +# to one shared instance. +flate2 = "1" +brotli = "7" # Release-profile tuning. `panic = "abort"` is DELIBERATELY NOT set: the # `declare!` glue in `ephpm-middleware` uses `catch_unwind` to fail CLOSED on a diff --git a/README.md b/README.md index 09dc9ad..ed59d22 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,14 @@ rather than compile into the server. > the repo is private those downloads require a token. The owner flips it > public when ready. -ePHPm runs middleware **in front of PHP, before PHP dispatch** — reject, -rewrite, or annotate a request at native speed, with direct access to the -embedded (cluster-replicated) KV store. See the +ePHPm runs middleware in two phases. The **request phase** runs **in front of +PHP, before PHP dispatch** — reject, rewrite, or annotate a request at native +speed, with direct access to the embedded (cluster-replicated) KV store; it +fails **closed**. The optional **response phase** runs **after** the response +is generated (PHP, static file, or error page), in reverse chain order, to +*transform* it — compression, header injection, correlation ids; it fails +**safe** and is not a security gate. A module opts into the response phase with +`declare!(Type, response)`. See the [Native Middleware guide](https://github.com/ephpm/ephpm/blob/main/site/content/guides/native-middleware.md) for the operator view and chain semantics. @@ -28,6 +33,17 @@ for the operator view and chain semantics. | `security-headers` | `ephpm-middleware-security-headers` | Append standard security response headers (HSTS, CSP, `X-Frame-Options`, …). | | `maintenance-mode` | `ephpm-middleware-maintenance-mode` | Flip a tenant into a `503` holding page via a per-site KV flag — no redeploy (`Retry-After`; IP/path bypass; fails **open**). | | `ip-allowlist` | `ephpm-middleware-ip-allowlist` | Allow/deny requests by client IP against CIDR lists, fail-closed (`403`); deny beats allow. | +| `request-id` | `ephpm-middleware-request-id` | **Request + response phase.** Give every request a correlation id: generate or honor an inbound `X-Request-Id`, inject it for PHP, and echo it on the response. | +| `header-transform` | `ephpm-middleware-header-transform` | **Request + response phase.** Set request headers seen by PHP; set/remove response headers out. | +| `compression` | `ephpm-middleware-compression` | **Response phase.** gzip/brotli the buffered body with `Accept-Encoding` negotiation. Skips already-encoded responses, so it never double-encodes — see the note below. | + +> **`compression` overlaps ePHPm's built-in compressor.** ePHPm already +> compresses buffered responses by default (`[server.response] compression`, +> **on**, brotli-then-gzip, before the response phase). The `compression` +> module stands down whenever a `Content-Encoding` is already present, so on a +> stock server it is **inert** — mount it only when core compression is turned +> **off** (`compression = false`) but you still want per-mount compression. It +> will not double-encode. See the crate's module docs. Per-module configuration keys are documented in each crate's module docs (`crates/ephpm-middleware-/src/lib.rs` re-exports the implementation from @@ -103,8 +119,16 @@ crates/ ephpm-middleware-security-headers cdylib shell ephpm-middleware-maintenance-mode cdylib shell ephpm-middleware-ip-allowlist cdylib shell + ephpm-middleware-request-id cdylib shell: declare!(RequestId, response) + ephpm-middleware-header-transform cdylib shell: declare!(HeaderTransform, response) + ephpm-middleware-compression cdylib shell: declare!(Compress, response) ``` +The last three opt into the **response phase** with `declare!(Type, response)` +— the host runs their `invoke_response` after the response is generated to +transform it, in addition to (or, for `compression`, instead of) a request +phase. + The impl/shell split is deliberate: multiple crates each exporting the same `ephpm_middleware_*` symbols cannot be linked into one binary, so the implementations live symbol-free in `ephpm-middleware-modules` and each cdylib diff --git a/crates/ephpm-middleware-compression/Cargo.toml b/crates/ephpm-middleware-compression/Cargo.toml new file mode 100644 index 0000000..420fc37 --- /dev/null +++ b/crates/ephpm-middleware-compression/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ephpm-middleware-compression" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ePHPm native middleware: response-phase gzip/brotli body compression with Accept-Encoding negotiation (skips already-encoded responses; loadable cdylib; implementation in ephpm-middleware-modules)" + +[lib] +# cdylib = the loadable module for the dlopen lane; rlib for tests + the +# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision +# rationale behind the impl/shell split. +crate-type = ["cdylib", "rlib"] + +[dependencies] +ephpm-middleware.workspace = true +ephpm-middleware-modules.workspace = true + +[lints] +workspace = true diff --git a/crates/ephpm-middleware-compression/src/lib.rs b/crates/ephpm-middleware-compression/src/lib.rs new file mode 100644 index 0000000..0a0cb90 --- /dev/null +++ b/crates/ephpm-middleware-compression/src/lib.rs @@ -0,0 +1,15 @@ +//! `compression` — loadable cdylib shell around the shared implementation in +//! [`ephpm_middleware_modules::compression`]. +//! +//! The middleware itself (response-phase gzip/brotli negotiation and the +//! anti-double-encode guards, docs and tests included) lives in +//! `ephpm-middleware-modules`. This crate only adds the C ABI exports +//! (`declare!(Compress, response)`) for the `dlopen` lane. +//! +//! Note: ePHPm's core already compresses buffered responses by default. This +//! module stands down when a `Content-Encoding` is already present — see the +//! implementation's module docs for when to actually mount it. + +pub use ephpm_middleware_modules::compression::Compress; + +ephpm_middleware::declare!(Compress, response); diff --git a/crates/ephpm-middleware-header-transform/Cargo.toml b/crates/ephpm-middleware-header-transform/Cargo.toml new file mode 100644 index 0000000..08642fe --- /dev/null +++ b/crates/ephpm-middleware-header-transform/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ephpm-middleware-header-transform" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ePHPm native middleware: set request headers seen by PHP and set/remove response headers out (request + response phase; loadable cdylib; implementation in ephpm-middleware-modules)" + +[lib] +# cdylib = the loadable module for the dlopen lane; rlib for tests + the +# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision +# rationale behind the impl/shell split. +crate-type = ["cdylib", "rlib"] + +[dependencies] +ephpm-middleware.workspace = true +ephpm-middleware-modules.workspace = true + +[lints] +workspace = true diff --git a/crates/ephpm-middleware-header-transform/src/lib.rs b/crates/ephpm-middleware-header-transform/src/lib.rs new file mode 100644 index 0000000..cb560f4 --- /dev/null +++ b/crates/ephpm-middleware-header-transform/src/lib.rs @@ -0,0 +1,11 @@ +//! `header-transform` — loadable cdylib shell around the shared implementation +//! in [`ephpm_middleware_modules::header_transform`]. +//! +//! The middleware itself (request/response header set + response header remove, +//! the request + response phase logic, docs and tests included) lives in +//! `ephpm-middleware-modules`. This crate only adds the C ABI exports +//! (`declare!(HeaderTransform, response)`) for the `dlopen` lane. + +pub use ephpm_middleware_modules::header_transform::HeaderTransform; + +ephpm_middleware::declare!(HeaderTransform, response); diff --git a/crates/ephpm-middleware-modules/Cargo.toml b/crates/ephpm-middleware-modules/Cargo.toml index e306184..ad3447b 100644 --- a/crates/ephpm-middleware-modules/Cargo.toml +++ b/crates/ephpm-middleware-modules/Cargo.toml @@ -27,6 +27,9 @@ base64ct.workspace = true ipnetwork.workspace = true # api-key: constant-time key comparison (no_std, no transitive deps). subtle.workspace = true +# compression: gzip (flate2) + brotli for the response-phase body transform. +flate2.workspace = true +brotli.workspace = true [dev-dependencies] # `host` gives the tests `RequestCtx` / `host_table` to fabricate a request, diff --git a/crates/ephpm-middleware-modules/src/compression.rs b/crates/ephpm-middleware-modules/src/compression.rs new file mode 100644 index 0000000..34871ea --- /dev/null +++ b/crates/ephpm-middleware-modules/src/compression.rs @@ -0,0 +1,557 @@ +//! `compression` — ePHPm native **response-phase** middleware that compresses +//! a buffered response body with `Accept-Encoding` negotiation (brotli, then +//! gzip), sets `Content-Encoding` / `Vary`, and lets the host recompute +//! `Content-Length`. +//! +//! Analogous to nginx `gzip`/`ngx_brotli`, Caddy's `encode`, or Traefik's +//! `compress` middleware. +//! +//! # ⚠ Overlaps ePHPm's built-in compression — read before mounting +//! +//! **ePHPm already compresses buffered responses by default.** The core server +//! runs brotli-then-gzip on buffered PHP and static responses whenever +//! `[server.response] compression` is on — and it *defaults to on* +//! (`compression = true`, `compression_min_size = 1024`) — negotiating +//! `Accept-Encoding`, setting `Content-Encoding` and `Vary`, and running +//! **before** the response phase. So on a stock server the response reaching +//! this module is *already* `Content-Encoding`-tagged. +//! +//! This module is therefore built to be **inert by default and never +//! double-encode**: it skips any response that already carries a +//! `Content-Encoding`. It only does real work when the operator has turned the +//! core compressor **off** (`[server.response] compression = false`) but still +//! wants compression on a specific mount — or on a build/config where core +//! compression is disabled. Mounting it does not conflict with core +//! compression; it simply stands down when the core already compressed. +//! +//! # Phase +//! +//! Response phase only. The request phase ([`Middleware::invoke`]) is a no-op +//! `CONTINUE`; all work happens in +//! [`ResponseMiddleware::invoke_response`]. Streamed responses never reach the +//! response phase (v1 is buffered-only), so a streamed/SSE body is untouched. +//! +//! # What it skips (besides an existing `Content-Encoding`) +//! +//! - a body smaller than `min_size`, or an empty body; +//! - a no-body / partial status (`204`, `304`, `1xx`, `206`); +//! - a `Content-Range` response (a range/partial transfer); +//! - `Cache-Control: no-transform` (RFC 9111 forbids transforming it); +//! - a `Content-Type` outside the compressible set; +//! - a request whose `Accept-Encoding` offers neither an enabled algorithm; +//! - a body that does not actually get smaller. +//! +//! Configuration (`[[middleware]] config = { ... }`), all optional: +//! +//! | key | default | meaning | +//! |-----|---------|---------| +//! | `brotli` (bool) | `true` | offer brotli (`Content-Encoding: br`), preferred when the client accepts it | +//! | `gzip` (bool) | `true` | offer gzip (`Content-Encoding: gzip`) | +//! | `level` (int 1–9) | `5` | effort: gzip level, and brotli quality (clamped to 0–11) | +//! | `min_size` (int) | `1024` | do not compress a body smaller than this many bytes | +//! | `types` (array of strings) | text/JSON/JS/XML/SVG | a `Content-Type` is compressible when it contains any of these substrings (case-insensitive) | + +use std::io::Write; + +use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; +use flate2::Compression; +use flate2::write::GzEncoder; + +/// Encoder scratch-buffer size, matching ePHPm's own buffered brotli path. +const BROTLI_BUF: usize = 4096; +/// Brotli window (log2): 4 MiB, matching ePHPm's buffered path. +const BROTLI_LGWIN: u32 = 22; + +/// The default compressible `Content-Type` substrings — mirrors ePHPm's own +/// `is_compressible`. +const DEFAULT_TYPES: &[&str] = &["text/", "javascript", "json", "xml", "svg"]; + +/// The negotiated encoding to apply. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Encoding { + Brotli, + Gzip, +} + +impl Encoding { + fn token(self) -> &'static str { + match self { + Encoding::Brotli => "br", + Encoding::Gzip => "gzip", + } + } +} + +/// Compression policy, built once at `init`. +pub struct Compress { + brotli: bool, + gzip: bool, + level: u32, + min_size: usize, + types: Vec, +} + +/// Read an optional boolean config key with a default. +fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default), + Some(serde_json::Value::Bool(b)) => Ok(*b), + Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), + } +} + +/// Read an optional unsigned-integer config key with a default. +fn opt_u64(config: &serde_json::Value, key: &str, default: u64) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default), + Some(v) => { + v.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer, got {v}")) + } + } +} + +/// True when `content_type` matches any configured compressible substring. +fn is_compressible(content_type: &str, types: &[String]) -> bool { + let ct = content_type.to_ascii_lowercase(); + types.iter().any(|t| ct.contains(t.as_str())) +} + +/// True when the response status carries no body or a partial body and must not +/// be compressed. +fn status_forbids_compression(status: u16) -> bool { + status < 200 || status == 204 || status == 304 || status == 206 +} + +/// Parse `Accept-Encoding` and return the q-weight the client assigned `token` +/// (or `*`), or `None` when the token is not acceptable (absent, or `q=0`). +fn accepts(accept_encoding: &str, token: &str) -> bool { + let mut wildcard: Option = None; + let mut explicit: Option = None; + for part in accept_encoding.split(',') { + let mut fields = part.split(';'); + let Some(name) = fields.next().map(str::trim) else { continue }; + // q defaults to 1.0 unless a `q=` parameter says otherwise. + let mut acceptable = true; + for param in fields { + let param = param.trim(); + if let Some(q) = param.strip_prefix("q=") { + acceptable = q.trim().parse::().is_ok_and(|v| v > 0.0); + } + } + if name.eq_ignore_ascii_case(token) { + explicit = Some(acceptable); + } else if name == "*" { + wildcard = Some(acceptable); + } + } + explicit.or(wildcard).unwrap_or(false) +} + +/// Gzip-compress `data` at `level` (1–9). +fn gzip(data: &[u8], level: u32) -> Option> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::new(level.clamp(1, 9))); + encoder.write_all(data).ok()?; + encoder.finish().ok() +} + +/// Brotli-compress `data` at quality derived from `level` (clamped to 0–11). +fn brotli(data: &[u8], level: u32) -> Option> { + let quality = level.min(11); + let mut out = Vec::new(); + { + let mut encoder = + brotli::CompressorWriter::new(&mut out, BROTLI_BUF, quality, BROTLI_LGWIN); + encoder.write_all(data).ok()?; + // CompressorWriter flushes the trailer on drop. + } + Some(out) +} + +impl Compress { + /// Choose the encoding to apply given the client's `Accept-Encoding`, + /// honoring the brotli-preferred order. + fn negotiate(&self, accept_encoding: &str) -> Option { + if self.brotli && accepts(accept_encoding, "br") { + return Some(Encoding::Brotli); + } + if self.gzip && accepts(accept_encoding, "gzip") { + return Some(Encoding::Gzip); + } + None + } + + /// Add `Accept-Encoding` to the response's `Vary`, preserving any existing + /// tokens and avoiding a duplicate. + fn apply_vary(resp: &mut ResponseView<'_>) { + match resp.header("Vary") { + Some(existing) => { + let already = + existing.split(',').any(|t| t.trim().eq_ignore_ascii_case("accept-encoding")); + if already { + return; + } + if existing.trim().eq_ignore_ascii_case("*") { + return; + } + resp.set_header("Vary", format!("{}, Accept-Encoding", existing.trim())); + } + None => resp.set_header("Vary", "Accept-Encoding"), + } + } +} + +impl Middleware for Compress { + fn init(config: &serde_json::Value) -> Result { + let level = opt_u64(config, "level", 5)?; + if !(1..=9).contains(&level) { + return Err(format!("`level` must be between 1 and 9, got {level}")); + } + let min_size = usize::try_from(opt_u64(config, "min_size", 1024)?) + .map_err(|_| "`min_size` is too large".to_string())?; + + let types = match config.get("types") { + None | Some(serde_json::Value::Null) => { + DEFAULT_TYPES.iter().map(|s| (*s).to_owned()).collect() + } + Some(serde_json::Value::Array(items)) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let s = item + .as_str() + .ok_or_else(|| format!("`types` entries must be strings, got {item}"))?; + if !s.is_empty() { + out.push(s.to_ascii_lowercase()); + } + } + out + } + Some(other) => return Err(format!("`types` must be an array, got {other}")), + }; + + let brotli = opt_bool(config, "brotli", true)?; + let gzip = opt_bool(config, "gzip", true)?; + if !brotli && !gzip { + return Err("at least one of `brotli` / `gzip` must be enabled".into()); + } + + Ok(Self { brotli, gzip, level: u32::try_from(level).unwrap_or(5), min_size, types }) + } + + fn invoke(&self, _req: &Request<'_>) -> Response { + // All work happens in the response phase. + Response::cont() + } +} + +impl ResponseMiddleware for Compress { + fn invoke_response(&self, req: &Request<'_>, resp: &mut ResponseView<'_>) { + // 1. Never double-encode: if the body already carries a + // Content-Encoding (e.g. ePHPm's core compressor already ran, or PHP + // encoded it), stand down. + if resp.header("Content-Encoding").is_some_and(|v| !v.trim().is_empty()) { + return; + } + // 2. No-body / partial statuses. + if status_forbids_compression(resp.status()) { + return; + } + // 3. Range/partial responses. + if resp.header("Content-Range").is_some() { + return; + } + // 4. Explicit no-transform. + if resp + .header("Cache-Control") + .is_some_and(|v| v.to_ascii_lowercase().contains("no-transform")) + { + return; + } + // 5. Content-Type gate. + let content_type = resp.header("Content-Type").unwrap_or_default(); + if !is_compressible(&content_type, &self.types) { + return; + } + // 6. Negotiate against Accept-Encoding. + let accept = req.header("Accept-Encoding").unwrap_or(""); + let Some(encoding) = self.negotiate(accept) else { + return; + }; + // 7. Size floor. + let body = resp.body(); + if body.len() < self.min_size { + return; + } + + let compressed = match encoding { + Encoding::Brotli => brotli(body, self.level), + Encoding::Gzip => gzip(body, self.level), + }; + // 8. Only apply when it actually helped. + let Some(compressed) = compressed.filter(|c| c.len() < body.len()) else { + return; + }; + + resp.set_header("Content-Encoding", encoding.token()); + Self::apply_vary(resp); + // The host recomputes Content-Length from the replacement body. + resp.set_body(compressed); + } +} + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. + + use std::io::Read; + + use ephpm_middleware::abi::ACTION_CONTINUE; + use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; + + use super::*; + + fn init(config: serde_json::Value) -> Compress { + Compress::init(&config).expect("init") + } + + fn hdr(name: &str, value: &str) -> (String, String) { + (name.to_owned(), value.to_owned()) + } + + /// Drive the response phase; return `(headers, body)` after applying edits. + fn run( + mw: &Compress, + accept_encoding: &str, + status: u16, + resp_headers: Vec<(String, String)>, + body: &[u8], + ) -> (Vec<(String, String)>, Vec) { + let req_headers: Vec<(String, String)> = if accept_encoding.is_empty() { + vec![] + } else { + vec![hdr("Accept-Encoding", accept_encoding)] + }; + let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &req_headers); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + let mut rctx = ResponseCtx::new(status, resp_headers, body.to_vec()); + { + // SAFETY: `rctx` outlives the view; host_table() is 'static. + let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; + mw.invoke_response(&req, &mut view); + let (st, b, set, remove) = view.__into_parts(); + for name in remove { + rctx.remove_header(&name); + } + for (n, v) in set { + rctx.set_header(&n, &v); + } + if let Some(s) = st { + rctx.set_status(s); + } + if let Some(b) = b { + rctx.replace_body(b); + } + } + let (_status, headers, out_body) = rctx.into_parts(); + (headers, out_body) + } + + fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) + } + + fn html_body() -> Vec { + // Highly compressible, comfortably over the 1 KiB floor. + "".bytes().chain(std::iter::repeat_n(b'a', 4096)).collect() + } + + fn gunzip(data: &[u8]) -> Vec { + let mut d = flate2::read::GzDecoder::new(data); + let mut out = Vec::new(); + d.read_to_end(&mut out).expect("gunzip"); + out + } + + // ── request phase is a no-op ────────────────────────────────────────── + + #[test] + fn request_phase_continues() { + let mw = init(serde_json::Value::Null); + let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + assert_eq!(mw.invoke(&req).__action(), ACTION_CONTINUE); + } + + // ── happy paths ─────────────────────────────────────────────────────── + + #[test] + fn gzip_when_only_gzip_accepted() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, out) = run(&mw, "gzip", 200, vec![hdr("Content-Type", "text/html")], &body); + assert_eq!(get(&headers, "Content-Encoding"), Some("gzip")); + assert_eq!(get(&headers, "Vary"), Some("Accept-Encoding")); + assert!(out.len() < body.len()); + assert_eq!(gunzip(&out), body, "gzip stream must round-trip"); + } + + #[test] + fn brotli_preferred_over_gzip() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, out) = + run(&mw, "gzip, br", 200, vec![hdr("Content-Type", "text/html")], &body); + assert_eq!(get(&headers, "Content-Encoding"), Some("br")); + assert!(out.len() < body.len()); + } + + #[test] + fn gzip_used_when_brotli_disabled() { + let mw = init(serde_json::json!({ "brotli": false })); + let body = html_body(); + let (headers, _out) = + run(&mw, "gzip, br", 200, vec![hdr("Content-Type", "text/html")], &body); + assert_eq!(get(&headers, "Content-Encoding"), Some("gzip")); + } + + // ── skip conditions ─────────────────────────────────────────────────── + + #[test] + fn skips_when_already_encoded() { + // This is the core-compression-already-ran case: do NOT double-encode. + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, out) = run( + &mw, + "br", + 200, + vec![hdr("Content-Type", "text/html"), hdr("Content-Encoding", "br")], + &body, + ); + assert_eq!(get(&headers, "Content-Encoding"), Some("br")); + assert_eq!(out, body, "body must be left untouched"); + } + + #[test] + fn skips_uncompressible_content_type() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, out) = run(&mw, "br", 200, vec![hdr("Content-Type", "image/png")], &body); + assert_eq!(get(&headers, "Content-Encoding"), None); + assert_eq!(out, body); + } + + #[test] + fn skips_small_body() { + let mw = init(serde_json::Value::Null); + let body = b"tiny".to_vec(); + let (headers, out) = run(&mw, "br", 200, vec![hdr("Content-Type", "text/html")], &body); + assert_eq!(get(&headers, "Content-Encoding"), None); + assert_eq!(out, body); + } + + #[test] + fn skips_when_client_accepts_nothing() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, _out) = + run(&mw, "identity", 200, vec![hdr("Content-Type", "text/html")], &body); + assert_eq!(get(&headers, "Content-Encoding"), None); + } + + #[test] + fn skips_q0_encoding() { + let mw = init(serde_json::json!({ "brotli": false })); + let body = html_body(); + let (headers, _out) = + run(&mw, "gzip;q=0", 200, vec![hdr("Content-Type", "text/html")], &body); + assert_eq!(get(&headers, "Content-Encoding"), None); + } + + #[test] + fn skips_no_transform() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, _out) = run( + &mw, + "br", + 200, + vec![hdr("Content-Type", "text/html"), hdr("Cache-Control", "private, no-transform")], + &body, + ); + assert_eq!(get(&headers, "Content-Encoding"), None); + } + + #[test] + fn skips_304() { + let mw = init(serde_json::Value::Null); + let (headers, _out) = + run(&mw, "br", 304, vec![hdr("Content-Type", "text/html")], &html_body()); + assert_eq!(get(&headers, "Content-Encoding"), None); + } + + #[test] + fn skips_content_range() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, _out) = run( + &mw, + "br", + 206, + vec![hdr("Content-Type", "text/html"), hdr("Content-Range", "bytes 0-99/200")], + &body, + ); + assert_eq!(get(&headers, "Content-Encoding"), None); + } + + // ── Vary preservation ───────────────────────────────────────────────── + + #[test] + fn vary_is_appended_not_clobbered() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, _out) = run( + &mw, + "gzip", + 200, + vec![hdr("Content-Type", "text/html"), hdr("Vary", "Cookie")], + &body, + ); + assert_eq!(get(&headers, "Vary"), Some("Cookie, Accept-Encoding")); + } + + #[test] + fn vary_not_duplicated() { + let mw = init(serde_json::Value::Null); + let body = html_body(); + let (headers, _out) = run( + &mw, + "gzip", + 200, + vec![hdr("Content-Type", "text/html"), hdr("Vary", "Accept-Encoding")], + &body, + ); + assert_eq!(get(&headers, "Vary"), Some("Accept-Encoding")); + } + + // ── config validation ───────────────────────────────────────────────── + + #[test] + fn bad_config_fails_init() { + assert!(Compress::init(&serde_json::json!({ "level": 0 })).is_err()); + assert!(Compress::init(&serde_json::json!({ "level": 10 })).is_err()); + assert!(Compress::init(&serde_json::json!({ "brotli": false, "gzip": false })).is_err()); + assert!(Compress::init(&serde_json::json!({ "types": "text/" })).is_err()); + assert!(Compress::init(&serde_json::json!({ "min_size": -1 })).is_err()); + } + + #[test] + fn custom_types_are_honored() { + let mw = init(serde_json::json!({ "types": ["application/octet-stream"] })); + let body: Vec = std::iter::repeat_n(b'a', 4096).collect(); + let (headers, _out) = + run(&mw, "gzip", 200, vec![hdr("Content-Type", "application/octet-stream")], &body); + assert_eq!(get(&headers, "Content-Encoding"), Some("gzip")); + } +} diff --git a/crates/ephpm-middleware-modules/src/header_transform.rs b/crates/ephpm-middleware-modules/src/header_transform.rs new file mode 100644 index 0000000..1825552 --- /dev/null +++ b/crates/ephpm-middleware-modules/src/header_transform.rs @@ -0,0 +1,302 @@ +//! `header-transform` — ePHPm native middleware that rewrites request headers +//! seen by PHP and response headers sent to the client. +//! +//! Analogous to Traefik's `headers` (`customRequestHeaders` / +//! `customResponseHeaders`), Kong's request/response transformer, or nginx's +//! `proxy_set_header` / `add_header` / `more_clear_headers`. +//! +//! # Two phases +//! +//! - **Request phase** ([`Middleware::invoke`]) — set request headers before +//! PHP runs (PHP reads them as `$_SERVER['HTTP_']`). +//! - **Response phase** ([`ResponseMiddleware::invoke_response`]) — set or +//! remove response headers on the way out, on **every** response (PHP, +//! static file, error page). +//! +//! Configuration (`[[middleware]] config = { ... }`), all optional: +//! +//! ```toml +//! [middleware.config.request] +//! set = { "X-Env" = "prod", "X-Tenant" = "acme" } +//! +//! [middleware.config.response] +//! set = { "X-Served-By" = "ephpm" } +//! remove = ["Server", "X-Powered-By"] +//! ``` +//! +//! | section | key | effect | +//! |---------|-----|--------| +//! | `request` | `set` (object) | replace-or-add each request header PHP sees | +//! | `response` | `set` (object) | replace-or-add each response header | +//! | `response` | `remove` (array) | delete each response header (case-insensitive) | +//! +//! # v1 ABI scope (why there is no `add` or request `remove`) +//! +//! The host applies both request-header overrides and response `set` as +//! **replace-or-add** (one occurrence, case-insensitive) — there is no +//! duplicate-append primitive in the v1 middleware ABI, so `add` and `set` +//! would be identical; only `set` is offered. And the request phase can only +//! *override* a request header, not delete one, so request-side `remove` is +//! not offered (it would be a silent no-op). Header **removal is response-side +//! only**, where the ABI supports it. Both are honest reflections of the ABI, +//! not omissions. + +use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; + +/// A parsed set of `(name, value)` header assignments, order preserved. +type Assignments = Vec<(String, String)>; + +/// Header rewrite policy, built once at `init`. +pub struct HeaderTransform { + request_set: Assignments, + response_set: Assignments, + response_remove: Vec, +} + +/// Parse an optional `{ name: value, ... }` object into ordered string pairs. +/// Rejects non-string values and empty names. +fn parse_set(section: &serde_json::Value, path: &str) -> Result { + match section.get("set") { + None | Some(serde_json::Value::Null) => Ok(Vec::new()), + Some(serde_json::Value::Object(map)) => { + let mut out = Vec::with_capacity(map.len()); + for (name, value) in map { + if name.is_empty() { + return Err(format!("`{path}.set` has an empty header name")); + } + let v = value.as_str().ok_or_else(|| { + format!("`{path}.set` values must be strings, got {value} for `{name}`") + })?; + out.push((name.clone(), v.to_owned())); + } + Ok(out) + } + Some(other) => Err(format!("`{path}.set` must be an object, got {other}")), + } +} + +/// Parse an optional `["Name", ...]` array of header names to remove. +fn parse_remove(section: &serde_json::Value, path: &str) -> Result, String> { + match section.get("remove") { + None | Some(serde_json::Value::Null) => Ok(Vec::new()), + Some(serde_json::Value::Array(items)) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let name = item.as_str().ok_or_else(|| { + format!("`{path}.remove` entries must be strings, got {item}") + })?; + if name.is_empty() { + return Err(format!("`{path}.remove` has an empty header name")); + } + out.push(name.to_owned()); + } + Ok(out) + } + Some(other) => Err(format!("`{path}.remove` must be an array, got {other}")), + } +} + +/// Fetch a top-level section object (`request` / `response`), defaulting to a +/// JSON null (an empty section) when absent. Rejects a non-object section. +fn section<'a>(config: &'a serde_json::Value, key: &str) -> Result<&'a serde_json::Value, String> { + const NULL: serde_json::Value = serde_json::Value::Null; + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(&NULL), + Some(v @ serde_json::Value::Object(_)) => Ok(v), + Some(other) => Err(format!("`{key}` must be an object, got {other}")), + } +} + +impl Middleware for HeaderTransform { + fn init(config: &serde_json::Value) -> Result { + let request = section(config, "request")?; + let response = section(config, "response")?; + + // `request.remove` cannot be honored (see module docs); reject it loudly + // rather than silently ignore it. + if request.get("remove").is_some_and(|v| !v.is_null()) { + return Err("`request.remove` is not supported: the v1 ABI request phase can \ + only set/override request headers, not delete them" + .into()); + } + + Ok(Self { + request_set: parse_set(request, "request")?, + response_set: parse_set(response, "response")?, + response_remove: parse_remove(response, "response")?, + }) + } + + fn invoke(&self, _req: &Request<'_>) -> Response { + if self.request_set.is_empty() { + return Response::cont(); + } + let mut r = Response::rewrite(); + for (name, value) in &self.request_set { + r = r.header(name.clone(), value.clone()); + } + r + } +} + +impl ResponseMiddleware for HeaderTransform { + fn invoke_response(&self, _req: &Request<'_>, resp: &mut ResponseView<'_>) { + for name in &self.response_remove { + resp.remove_header(name.clone()); + } + for (name, value) in &self.response_set { + resp.set_header(name.clone(), value.clone()); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. + + use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_REWRITE}; + use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; + + use super::*; + + fn init(config: serde_json::Value) -> HeaderTransform { + HeaderTransform::init(&config).expect("init") + } + + fn hdr(name: &str, value: &str) -> (String, String) { + (name.to_owned(), value.to_owned()) + } + + fn invoke(mw: &HeaderTransform) -> Response { + let ctx = RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) + } + + fn invoke_response( + mw: &HeaderTransform, + resp_headers: Vec<(String, String)>, + ) -> Vec<(String, String)> { + let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + let mut rctx = ResponseCtx::new(200, resp_headers, b"body".to_vec()); + { + // SAFETY: `rctx` outlives the view; host_table() is 'static. + let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; + mw.invoke_response(&req, &mut view); + let (status, body, set, remove) = view.__into_parts(); + for name in remove { + rctx.remove_header(&name); + } + for (n, v) in set { + rctx.set_header(&n, &v); + } + if let Some(s) = status { + rctx.set_status(s); + } + if let Some(b) = body { + rctx.replace_body(b); + } + } + let (_status, headers, _body) = rctx.into_parts(); + headers + } + + fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) + } + + // ── request phase ───────────────────────────────────────────────────── + + #[test] + fn request_set_injects_headers() { + let mw = init(serde_json::json!({ + "request": { "set": { "X-Env": "prod", "X-Tenant": "acme" } } + })); + let resp = invoke(&mw); + assert_eq!(resp.__action(), ACTION_REWRITE); + assert_eq!(get(resp.__headers(), "X-Env"), Some("prod")); + assert_eq!(get(resp.__headers(), "X-Tenant"), Some("acme")); + } + + #[test] + fn no_request_config_continues() { + let mw = init(serde_json::json!({ + "response": { "set": { "X-A": "b" } } + })); + assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); + } + + // ── response phase ──────────────────────────────────────────────────── + + #[test] + fn response_set_replaces_or_adds() { + let mw = init(serde_json::json!({ + "response": { "set": { "X-Served-By": "ephpm", "Content-Type": "text/plain" } } + })); + let out = invoke_response(&mw, vec![hdr("Content-Type", "text/html")]); + assert_eq!(get(&out, "X-Served-By"), Some("ephpm")); + // Replace, not duplicate. + assert_eq!(get(&out, "Content-Type"), Some("text/plain")); + assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Content-Type")).count(), 1); + } + + #[test] + fn response_remove_deletes() { + let mw = init(serde_json::json!({ + "response": { "remove": ["Server", "X-Powered-By"] } + })); + let out = invoke_response( + &mw, + vec![hdr("Server", "nginx"), hdr("X-Powered-By", "PHP/8.5"), hdr("X-Keep", "1")], + ); + assert_eq!(get(&out, "Server"), None); + assert_eq!(get(&out, "X-Powered-By"), None); + assert_eq!(get(&out, "X-Keep"), Some("1")); + } + + #[test] + fn remove_then_set_same_header_nets_set() { + let mw = init(serde_json::json!({ + "response": { "set": { "Server": "ephpm" }, "remove": ["Server"] } + })); + let out = invoke_response(&mw, vec![hdr("Server", "nginx")]); + assert_eq!(get(&out, "Server"), Some("ephpm")); + assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Server")).count(), 1); + } + + // ── config validation ───────────────────────────────────────────────── + + #[test] + fn request_remove_is_rejected() { + assert!( + HeaderTransform::init(&serde_json::json!({ + "request": { "remove": ["X-Foo"] } + })) + .is_err() + ); + } + + #[test] + fn bad_config_fails_init() { + assert!( + HeaderTransform::init(&serde_json::json!({ "request": { "set": { "X": 1 } } })) + .is_err() + ); + assert!( + HeaderTransform::init(&serde_json::json!({ "response": { "remove": [42] } })).is_err() + ); + assert!(HeaderTransform::init(&serde_json::json!({ "request": "nope" })).is_err()); + assert!(HeaderTransform::init(&serde_json::json!({ "response": { "set": [] } })).is_err()); + } + + #[test] + fn empty_config_is_a_noop() { + let mw = init(serde_json::Value::Null); + assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); + let out = invoke_response(&mw, vec![hdr("X-A", "b")]); + assert_eq!(get(&out, "X-A"), Some("b")); + } +} diff --git a/crates/ephpm-middleware-modules/src/lib.rs b/crates/ephpm-middleware-modules/src/lib.rs index c936fec..adf91d1 100644 --- a/crates/ephpm-middleware-modules/src/lib.rs +++ b/crates/ephpm-middleware-modules/src/lib.rs @@ -16,10 +16,13 @@ //! why the implementations live here. pub mod api_key; +pub mod compression; pub mod cors; +pub mod header_transform; pub mod ip_allowlist; pub mod jwt; pub mod maintenance_mode; pub mod ratelimit; pub mod redirect; +pub mod request_id; pub mod security_headers; diff --git a/crates/ephpm-middleware-modules/src/request_id.rs b/crates/ephpm-middleware-modules/src/request_id.rs new file mode 100644 index 0000000..9d4b794 --- /dev/null +++ b/crates/ephpm-middleware-modules/src/request_id.rs @@ -0,0 +1,388 @@ +//! `request-id` — ePHPm native middleware that gives every request a stable +//! correlation id, injects it for PHP, and echoes it on the response. +//! +//! Analogous to Caddy's `request_id`, Kong's `correlation-id`, Traefik's +//! request-id plugins, or nginx's `$request_id`. One id per request ties the +//! access log, the PHP application log, and the client's copy of the header +//! together. +//! +//! # Two phases +//! +//! - **Request phase** ([`Middleware::invoke`]) — resolve the id (honor a +//! trusted inbound header, otherwise generate a fresh UUIDv4), inject it as a +//! request header so PHP sees `$_SERVER['HTTP_
']`, and stage the same +//! value as a response header so the dynamic response carries exactly the id +//! PHP logged. +//! - **Response phase** ([`ResponseMiddleware::invoke_response`]) — guarantee +//! the header on responses the request phase never touched (the static-file +//! path runs **no** request phase) and stay idempotent on the PHP path. +//! +//! # Why the request phase also stages the response header +//! +//! The v1 response phase cannot see state its own request phase set — it is +//! handed a request view rebuilt from the *original* inbound headers, and it +//! may run with no preceding `invoke` at all (static files, an upstream +//! short-circuit). So a *generated* id known only to the request phase could +//! not be re-derived in the response phase; regenerating there would echo a +//! different id than PHP received. The request phase therefore carries the id +//! to the response itself (`response_header`), and the response phase only +//! *fills in* the header when it is absent — never overwrites it. +//! +//! Configuration (`[[middleware]] config = { ... }`), all optional: +//! +//! | key | default | meaning | +//! |-----|---------|---------| +//! | `header` (string) | `"X-Request-Id"` | the request/response header name carrying the id | +//! | `trust_inbound` (bool) | `false` | when true, reuse a well-formed inbound `header` value instead of generating; when false, always generate (the inbound value is ignored) | +//! +//! An inbound id is only trusted when it is a short, printable ASCII token +//! (no control characters, no whitespace, ≤ 200 bytes). A trusted value that +//! fails that check is replaced with a generated id rather than reflected — a +//! client must not be able to smuggle CR/LF or oversized junk into logs and +//! downstream headers through a "trusted" correlation id. + +use std::cell::Cell; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; + +/// Max length of an inbound id we are willing to reflect. +const MAX_INBOUND_LEN: usize = 200; + +/// Resolved request-id policy, built once at `init`. +pub struct RequestId { + /// Header name carrying the id (as configured; used verbatim on the wire). + header: String, + /// Whether a well-formed inbound value is reused instead of generated. + trust_inbound: bool, +} + +/// Read an optional string config key with a default. +fn opt_string(config: &serde_json::Value, key: &str, default: &str) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default.to_owned()), + Some(serde_json::Value::String(s)) => Ok(s.clone()), + Some(other) => Err(format!("`{key}` must be a string, got {other}")), + } +} + +/// Read an optional boolean config key with a default. +fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default), + Some(serde_json::Value::Bool(b)) => Ok(*b), + Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), + } +} + +/// True when `id` is a safe correlation token: non-empty, ≤ [`MAX_INBOUND_LEN`] +/// bytes, and every byte a printable ASCII graphic (no controls, no spaces). +fn is_safe_id(id: &str) -> bool { + !id.is_empty() && id.len() <= MAX_INBOUND_LEN && id.bytes().all(|b| b.is_ascii_graphic()) +} + +/// Per-process entropy mixed into every generated id, so two processes (or two +/// restarts) do not produce the same id stream. Seeded once from wall-clock +/// nanos and the address of a stack local. +static SEED: AtomicU64 = AtomicU64::new(0); + +thread_local! { + /// Per-thread SplitMix64 state. Lazily seeded from the process seed, the + /// thread identity, and a monotonic counter so distinct threads never + /// walk the same sequence. + static RNG: Cell = const { Cell::new(0) }; +} + +/// SplitMix64 — a tiny, fast, well-distributed 64-bit generator. Not +/// cryptographic; request ids need collision resistance, not unpredictability. +fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// Initialise the process seed exactly once. +fn ensure_seed() { + if SEED.load(Ordering::Relaxed) == 0 { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0x1234_5678, |d| d.as_nanos() as u64); + let local = 0u8; + let addr = std::ptr::from_ref(&local) as u64; + let mut s = nanos ^ addr.rotate_left(17) ^ 0xA5A5_5A5A_1234_ABCD; + if s == 0 { + s = 0xDEAD_BEEF_CAFE_F00D; + } + // Racy is fine: any winner leaves a usable non-zero seed. + SEED.store(s, Ordering::Relaxed); + } +} + +/// Draw the next two 64-bit words of randomness from the thread-local RNG. +fn next_u128() -> (u64, u64) { + ensure_seed(); + RNG.with(|cell| { + let mut state = cell.get(); + if state == 0 { + static THREAD_COUNTER: AtomicU64 = AtomicU64::new(1); + let tc = THREAD_COUNTER.fetch_add(1, Ordering::Relaxed); + state = SEED + .load(Ordering::Relaxed) + .wrapping_mul(0x2545_F491_4F6C_DD1D) + .wrapping_add(tc.rotate_left(32)); + if state == 0 { + state = 0x1; + } + } + let hi = splitmix64(&mut state); + let lo = splitmix64(&mut state); + cell.set(state); + (hi, lo) + }) +} + +/// Generate a random UUIDv4 string (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`). +fn generate_id() -> String { + let (mut hi, mut lo) = next_u128(); + // Version 4 in the high nibble of byte 6. + hi = (hi & 0xFFFF_FFFF_FFFF_0FFF) | 0x0000_0000_0000_4000; + // Variant 10xx in the two high bits of byte 8. + lo = (lo & 0x3FFF_FFFF_FFFF_FFFF) | 0x8000_0000_0000_0000; + let b = |v: u64, shift: u32| ((v >> shift) & 0xFF) as u8; + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + b(hi, 56), + b(hi, 48), + b(hi, 40), + b(hi, 32), + b(hi, 24), + b(hi, 16), + b(hi, 8), + b(hi, 0), + b(lo, 56), + b(lo, 48), + b(lo, 40), + b(lo, 32), + b(lo, 24), + b(lo, 16), + b(lo, 8), + b(lo, 0), + ) +} + +impl RequestId { + /// The id to use for this request: a trusted, well-formed inbound value + /// when `trust_inbound` is on, otherwise a freshly generated UUIDv4. + fn resolve(&self, inbound: Option<&str>) -> String { + if self.trust_inbound + && let Some(v) = inbound + && is_safe_id(v) + { + return v.to_owned(); + } + generate_id() + } +} + +impl Middleware for RequestId { + fn init(config: &serde_json::Value) -> Result { + let header = opt_string(config, "header", "X-Request-Id")?; + if header.is_empty() { + return Err("`header` must not be empty".into()); + } + Ok(Self { header, trust_inbound: opt_bool(config, "trust_inbound", false)? }) + } + + fn invoke(&self, req: &Request<'_>) -> Response { + let id = self.resolve(req.header(&self.header)); + // Inject the request header (PHP sees $_SERVER['HTTP_...']) AND echo the + // same value on the response, so the dynamic path carries exactly the + // id PHP logged. The response phase fills the header in only when it is + // still absent (e.g. the static-file path, which runs no request phase). + Response::rewrite() + .header(self.header.clone(), id.clone()) + .response_header(self.header.clone(), id) + } +} + +impl ResponseMiddleware for RequestId { + fn invoke_response(&self, req: &Request<'_>, resp: &mut ResponseView<'_>) { + // Idempotent: if the header is already present (request phase echoed it, + // or PHP set its own), leave it untouched — never overwrite or duplicate. + if resp.header(&self.header).is_some() { + return; + } + // No request phase ran for this response (static file / short-circuit), + // so honor a trusted inbound value or generate a fresh id. + let id = self.resolve(req.header(&self.header)); + resp.set_header(self.header.clone(), id); + } +} + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. + + use ephpm_middleware::abi::ACTION_REWRITE; + use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; + + use super::*; + + fn init(config: serde_json::Value) -> RequestId { + RequestId::init(&config).expect("init") + } + + fn hdr(name: &str, value: &str) -> (String, String) { + (name.to_owned(), value.to_owned()) + } + + fn invoke(mw: &RequestId, headers: &[(String, String)]) -> Response { + let ctx = RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", headers); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) + } + + /// Drive the response phase against a fabricated response, returning the + /// resulting `(status, headers, body)`. + fn invoke_response( + mw: &RequestId, + req_headers: &[(String, String)], + resp_status: u16, + resp_headers: Vec<(String, String)>, + resp_body: &[u8], + ) -> (u16, Vec<(String, String)>, Vec) { + let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", req_headers); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + let mut rctx = ResponseCtx::new(resp_status, resp_headers, resp_body.to_vec()); + { + // SAFETY: `rctx` outlives the view; host_table() is 'static. + let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; + mw.invoke_response(&req, &mut view); + let (status, body, set, remove) = view.__into_parts(); + for name in remove { + rctx.remove_header(&name); + } + for (n, v) in set { + rctx.set_header(&n, &v); + } + if let Some(s) = status { + rctx.set_status(s); + } + if let Some(b) = body { + rctx.replace_body(b); + } + } + rctx.into_parts() + } + + fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) + } + + fn looks_like_uuid(v: &str) -> bool { + v.len() == 36 && v.as_bytes()[14] == b'4' && v.chars().filter(|c| *c == '-').count() == 4 + } + + // ── request phase ───────────────────────────────────────────────────── + + #[test] + fn generates_and_injects_when_absent() { + let mw = init(serde_json::Value::Null); + let resp = invoke(&mw, &[]); + assert_eq!(resp.__action(), ACTION_REWRITE); + // Request header override (PHP-visible). + let req_id = get(resp.__headers(), "X-Request-Id").expect("request header"); + assert!(looks_like_uuid(req_id), "{req_id}"); + // Response echo — same value. + let resp_id = get(resp.__response_headers(), "X-Request-Id").expect("response header"); + assert_eq!(req_id, resp_id, "PHP and the client must see the same id"); + } + + #[test] + fn ignores_inbound_when_not_trusted() { + let mw = init(serde_json::Value::Null); + let resp = invoke(&mw, &[hdr("X-Request-Id", "client-supplied-123")]); + let id = get(resp.__headers(), "X-Request-Id").unwrap(); + assert_ne!(id, "client-supplied-123"); + assert!(looks_like_uuid(id), "{id}"); + } + + #[test] + fn honors_trusted_inbound() { + let mw = init(serde_json::json!({ "trust_inbound": true })); + let resp = invoke(&mw, &[hdr("X-Request-Id", "abc-123-DEF")]); + assert_eq!(get(resp.__headers(), "X-Request-Id"), Some("abc-123-DEF")); + assert_eq!(get(resp.__response_headers(), "X-Request-Id"), Some("abc-123-DEF")); + } + + #[test] + fn trusted_but_unsafe_inbound_is_regenerated() { + let mw = init(serde_json::json!({ "trust_inbound": true })); + // CR/LF injection attempt — must not be reflected. + let resp = invoke(&mw, &[hdr("X-Request-Id", "bad\r\nInjected: 1")]); + let id = get(resp.__headers(), "X-Request-Id").unwrap(); + assert!(looks_like_uuid(id), "{id}"); + } + + #[test] + fn custom_header_name() { + let mw = init(serde_json::json!({ "header": "X-Correlation-Id", "trust_inbound": true })); + let resp = invoke(&mw, &[hdr("X-Correlation-Id", "corr-1")]); + assert_eq!(get(resp.__headers(), "X-Correlation-Id"), Some("corr-1")); + } + + #[test] + fn generated_ids_are_unique() { + let mw = init(serde_json::Value::Null); + let a = get(invoke(&mw, &[]).__headers(), "X-Request-Id").unwrap().to_owned(); + let b = get(invoke(&mw, &[]).__headers(), "X-Request-Id").unwrap().to_owned(); + assert_ne!(a, b); + } + + // ── response phase ──────────────────────────────────────────────────── + + #[test] + fn response_phase_adds_header_when_absent() { + // Static-file path: no request phase ran, response has no id yet. + let mw = init(serde_json::Value::Null); + let (_status, headers, _body) = invoke_response(&mw, &[], 200, vec![], b"body"); + let id = get(&headers, "X-Request-Id").expect("id added"); + assert!(looks_like_uuid(id), "{id}"); + } + + #[test] + fn response_phase_is_idempotent_when_present() { + // PHP path: the request phase already echoed the id — do not overwrite. + let mw = init(serde_json::Value::Null); + let (_status, headers, _body) = + invoke_response(&mw, &[], 200, vec![hdr("X-Request-Id", "existing-id-42")], b"body"); + assert_eq!(get(&headers, "X-Request-Id"), Some("existing-id-42")); + // Exactly one occurrence — no duplicate. + assert_eq!( + headers.iter().filter(|(n, _)| n.eq_ignore_ascii_case("X-Request-Id")).count(), + 1 + ); + } + + #[test] + fn response_phase_honors_trusted_inbound_on_static_path() { + let mw = init(serde_json::json!({ "trust_inbound": true })); + let (_status, headers, _body) = + invoke_response(&mw, &[hdr("X-Request-Id", "inbound-77")], 200, vec![], b"body"); + assert_eq!(get(&headers, "X-Request-Id"), Some("inbound-77")); + } + + // ── config validation ───────────────────────────────────────────────── + + #[test] + fn bad_config_fails_init() { + assert!(RequestId::init(&serde_json::json!({ "header": "" })).is_err()); + assert!(RequestId::init(&serde_json::json!({ "header": 5 })).is_err()); + assert!(RequestId::init(&serde_json::json!({ "trust_inbound": "yes" })).is_err()); + } +} diff --git a/crates/ephpm-middleware-request-id/Cargo.toml b/crates/ephpm-middleware-request-id/Cargo.toml new file mode 100644 index 0000000..933d300 --- /dev/null +++ b/crates/ephpm-middleware-request-id/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ephpm-middleware-request-id" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ePHPm native middleware: per-request correlation id — generate/propagate X-Request-Id for PHP and echo it on the response (request + response phase; loadable cdylib; implementation in ephpm-middleware-modules)" + +[lib] +# cdylib = the loadable module for the dlopen lane; rlib for tests + the +# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision +# rationale behind the impl/shell split. +crate-type = ["cdylib", "rlib"] + +[dependencies] +ephpm-middleware.workspace = true +ephpm-middleware-modules.workspace = true + +[lints] +workspace = true diff --git a/crates/ephpm-middleware-request-id/src/lib.rs b/crates/ephpm-middleware-request-id/src/lib.rs new file mode 100644 index 0000000..da83ba8 --- /dev/null +++ b/crates/ephpm-middleware-request-id/src/lib.rs @@ -0,0 +1,11 @@ +//! `request-id` — loadable cdylib shell around the shared implementation in +//! [`ephpm_middleware_modules::request_id`]. +//! +//! The middleware itself (id generation/propagation, the request + response +//! phase logic, docs and tests included) lives in `ephpm-middleware-modules`. +//! This crate only adds the C ABI exports (`declare!(RequestId, response)`, so +//! both the request and response phase are exported) for the `dlopen` lane. + +pub use ephpm_middleware_modules::request_id::RequestId; + +ephpm_middleware::declare!(RequestId, response); From ef9214b9d2ae69ea03578b69fc63c82007dbdf91 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Sun, 23 Aug 2026 18:13:30 -0700 Subject: [PATCH 2/2] =?UTF-8?q?Drop=20the=20compression=20module=20?= =?UTF-8?q?=E2=80=94=20keep=20request-id=20+=20header-transform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response-body compression is redundant with ePHPm's built-in `[server.response] compression`, which is on by default and already runs brotli-then-gzip over buffered PHP/static responses (with Accept-Encoding negotiation, Vary, and Content-Length) BEFORE the response phase. A middleware compressor would therefore be redundant and inert on a stock server, so it is not shipped. Removes the compression impl, the ephpm-middleware-compression cdylib crate, its modules/lib.rs pub mod, its release.yml all_modules entry, its README table/layout rows, and the now-unused flate2/brotli workspace deps. request-id and header-transform are unchanged. --- .github/workflows/release.yml | 3 +- Cargo.lock | 10 - Cargo.toml | 12 +- README.md | 24 +- .../ephpm-middleware-compression/Cargo.toml | 21 - .../ephpm-middleware-compression/src/lib.rs | 15 - crates/ephpm-middleware-modules/Cargo.toml | 3 - .../src/compression.rs | 557 ------------------ crates/ephpm-middleware-modules/src/lib.rs | 1 - 9 files changed, 14 insertions(+), 632 deletions(-) delete mode 100644 crates/ephpm-middleware-compression/Cargo.toml delete mode 100644 crates/ephpm-middleware-compression/src/lib.rs delete mode 100644 crates/ephpm-middleware-modules/src/compression.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9018b4c..2e81d6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,8 +96,7 @@ jobs: {"short":"maintenance-mode", "crate":"ephpm-middleware-maintenance-mode"}, {"short":"ip-allowlist", "crate":"ephpm-middleware-ip-allowlist"}, {"short":"request-id", "crate":"ephpm-middleware-request-id"}, - {"short":"header-transform", "crate":"ephpm-middleware-header-transform"}, - {"short":"compression", "crate":"ephpm-middleware-compression"} + {"short":"header-transform", "crate":"ephpm-middleware-header-transform"} ]' if [ "$ONLY_HOST" = "true" ]; then diff --git a/Cargo.lock b/Cargo.lock index 2143753..672059b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,14 +220,6 @@ dependencies = [ "ephpm-middleware-modules", ] -[[package]] -name = "ephpm-middleware-compression" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", -] - [[package]] name = "ephpm-middleware-cors" version = "0.1.0" @@ -275,10 +267,8 @@ name = "ephpm-middleware-modules" version = "0.1.0" dependencies = [ "base64ct", - "brotli", "ephpm-kv", "ephpm-middleware", - "flate2", "hmac", "ipnetwork", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 50b5dc1..66e9cf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,9 @@ repository = "https://github.com/ephpm/middleware" # # Pinned at ePHPm main `e63284838d07d348e2155e76916daaf9782c012b` — the merge of # #408, which added the response-phase ABI hook (`ResponseMiddleware` / -# `declare!(Type, response)` / the `ResponseView` accessors) the request-id, -# header-transform and compression modules build on. Do NOT advance this to -# #409's scheme/host/body accessors: none of these three modules need them. +# `declare!(Type, response)` / the `ResponseView` accessors) the request-id and +# header-transform modules build on. Do NOT advance this to #409's +# scheme/host/body accessors: neither module needs them. # The rlib of shared module implementations — re-exported by the cdylib shells. ephpm-middleware-modules = { path = "crates/ephpm-middleware-modules" } ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } @@ -45,12 +45,6 @@ base64ct = { version = "1", features = ["alloc"] } # api-key: constant-time key comparison to close the timing oracle a naive `==` # would open. Tiny, no_std, no transitive deps. subtle = "2" -# compression: gzip + brotli for the response-phase body transform. Same crates -# and majors ePHPm's own buffered-compression path uses (`flate2 = "1"`, -# `brotli = "7"` in the ePHPm workspace), so a module vendored back in resolves -# to one shared instance. -flate2 = "1" -brotli = "7" # Release-profile tuning. `panic = "abort"` is DELIBERATELY NOT set: the # `declare!` glue in `ephpm-middleware` uses `catch_unwind` to fail CLOSED on a diff --git a/README.md b/README.md index ed59d22..ea5855a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ PHP, before PHP dispatch** — reject, rewrite, or annotate a request at native speed, with direct access to the embedded (cluster-replicated) KV store; it fails **closed**. The optional **response phase** runs **after** the response is generated (PHP, static file, or error page), in reverse chain order, to -*transform* it — compression, header injection, correlation ids; it fails +*transform* it — header injection, correlation ids; it fails **safe** and is not a security gate. A module opts into the response phase with `declare!(Type, response)`. See the [Native Middleware guide](https://github.com/ephpm/ephpm/blob/main/site/content/guides/native-middleware.md) @@ -35,15 +35,13 @@ for the operator view and chain semantics. | `ip-allowlist` | `ephpm-middleware-ip-allowlist` | Allow/deny requests by client IP against CIDR lists, fail-closed (`403`); deny beats allow. | | `request-id` | `ephpm-middleware-request-id` | **Request + response phase.** Give every request a correlation id: generate or honor an inbound `X-Request-Id`, inject it for PHP, and echo it on the response. | | `header-transform` | `ephpm-middleware-header-transform` | **Request + response phase.** Set request headers seen by PHP; set/remove response headers out. | -| `compression` | `ephpm-middleware-compression` | **Response phase.** gzip/brotli the buffered body with `Accept-Encoding` negotiation. Skips already-encoded responses, so it never double-encodes — see the note below. | -> **`compression` overlaps ePHPm's built-in compressor.** ePHPm already -> compresses buffered responses by default (`[server.response] compression`, -> **on**, brotli-then-gzip, before the response phase). The `compression` -> module stands down whenever a `Content-Encoding` is already present, so on a -> stock server it is **inert** — mount it only when core compression is turned -> **off** (`compression = false`) but you still want per-mount compression. It -> will not double-encode. See the crate's module docs. +> **No `compression` module.** Response-body compression is deliberately *not* +> shipped as a middleware: ePHPm's core already compresses buffered responses +> by default (`[server.response] compression`, **on**, brotli-then-gzip), +> negotiating `Accept-Encoding` and running **before** the response phase — so +> a middleware compressor would be redundant and inert on a stock server. Use +> the built-in knob, not a module. Per-module configuration keys are documented in each crate's module docs (`crates/ephpm-middleware-/src/lib.rs` re-exports the implementation from @@ -121,13 +119,11 @@ crates/ ephpm-middleware-ip-allowlist cdylib shell ephpm-middleware-request-id cdylib shell: declare!(RequestId, response) ephpm-middleware-header-transform cdylib shell: declare!(HeaderTransform, response) - ephpm-middleware-compression cdylib shell: declare!(Compress, response) ``` -The last three opt into the **response phase** with `declare!(Type, response)` -— the host runs their `invoke_response` after the response is generated to -transform it, in addition to (or, for `compression`, instead of) a request -phase. +The last two opt into the **response phase** with `declare!(Type, response)` — +the host runs their `invoke_response` after the response is generated to +transform it, in addition to their request phase. The impl/shell split is deliberate: multiple crates each exporting the same `ephpm_middleware_*` symbols cannot be linked into one binary, so the diff --git a/crates/ephpm-middleware-compression/Cargo.toml b/crates/ephpm-middleware-compression/Cargo.toml deleted file mode 100644 index 420fc37..0000000 --- a/crates/ephpm-middleware-compression/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "ephpm-middleware-compression" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: response-phase gzip/brotli body compression with Accept-Encoding negotiation (skips already-encoded responses; loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-compression/src/lib.rs b/crates/ephpm-middleware-compression/src/lib.rs deleted file mode 100644 index 0a0cb90..0000000 --- a/crates/ephpm-middleware-compression/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! `compression` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::compression`]. -//! -//! The middleware itself (response-phase gzip/brotli negotiation and the -//! anti-double-encode guards, docs and tests included) lives in -//! `ephpm-middleware-modules`. This crate only adds the C ABI exports -//! (`declare!(Compress, response)`) for the `dlopen` lane. -//! -//! Note: ePHPm's core already compresses buffered responses by default. This -//! module stands down when a `Content-Encoding` is already present — see the -//! implementation's module docs for when to actually mount it. - -pub use ephpm_middleware_modules::compression::Compress; - -ephpm_middleware::declare!(Compress, response); diff --git a/crates/ephpm-middleware-modules/Cargo.toml b/crates/ephpm-middleware-modules/Cargo.toml index ad3447b..e306184 100644 --- a/crates/ephpm-middleware-modules/Cargo.toml +++ b/crates/ephpm-middleware-modules/Cargo.toml @@ -27,9 +27,6 @@ base64ct.workspace = true ipnetwork.workspace = true # api-key: constant-time key comparison (no_std, no transitive deps). subtle.workspace = true -# compression: gzip (flate2) + brotli for the response-phase body transform. -flate2.workspace = true -brotli.workspace = true [dev-dependencies] # `host` gives the tests `RequestCtx` / `host_table` to fabricate a request, diff --git a/crates/ephpm-middleware-modules/src/compression.rs b/crates/ephpm-middleware-modules/src/compression.rs deleted file mode 100644 index 34871ea..0000000 --- a/crates/ephpm-middleware-modules/src/compression.rs +++ /dev/null @@ -1,557 +0,0 @@ -//! `compression` — ePHPm native **response-phase** middleware that compresses -//! a buffered response body with `Accept-Encoding` negotiation (brotli, then -//! gzip), sets `Content-Encoding` / `Vary`, and lets the host recompute -//! `Content-Length`. -//! -//! Analogous to nginx `gzip`/`ngx_brotli`, Caddy's `encode`, or Traefik's -//! `compress` middleware. -//! -//! # ⚠ Overlaps ePHPm's built-in compression — read before mounting -//! -//! **ePHPm already compresses buffered responses by default.** The core server -//! runs brotli-then-gzip on buffered PHP and static responses whenever -//! `[server.response] compression` is on — and it *defaults to on* -//! (`compression = true`, `compression_min_size = 1024`) — negotiating -//! `Accept-Encoding`, setting `Content-Encoding` and `Vary`, and running -//! **before** the response phase. So on a stock server the response reaching -//! this module is *already* `Content-Encoding`-tagged. -//! -//! This module is therefore built to be **inert by default and never -//! double-encode**: it skips any response that already carries a -//! `Content-Encoding`. It only does real work when the operator has turned the -//! core compressor **off** (`[server.response] compression = false`) but still -//! wants compression on a specific mount — or on a build/config where core -//! compression is disabled. Mounting it does not conflict with core -//! compression; it simply stands down when the core already compressed. -//! -//! # Phase -//! -//! Response phase only. The request phase ([`Middleware::invoke`]) is a no-op -//! `CONTINUE`; all work happens in -//! [`ResponseMiddleware::invoke_response`]. Streamed responses never reach the -//! response phase (v1 is buffered-only), so a streamed/SSE body is untouched. -//! -//! # What it skips (besides an existing `Content-Encoding`) -//! -//! - a body smaller than `min_size`, or an empty body; -//! - a no-body / partial status (`204`, `304`, `1xx`, `206`); -//! - a `Content-Range` response (a range/partial transfer); -//! - `Cache-Control: no-transform` (RFC 9111 forbids transforming it); -//! - a `Content-Type` outside the compressible set; -//! - a request whose `Accept-Encoding` offers neither an enabled algorithm; -//! - a body that does not actually get smaller. -//! -//! Configuration (`[[middleware]] config = { ... }`), all optional: -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `brotli` (bool) | `true` | offer brotli (`Content-Encoding: br`), preferred when the client accepts it | -//! | `gzip` (bool) | `true` | offer gzip (`Content-Encoding: gzip`) | -//! | `level` (int 1–9) | `5` | effort: gzip level, and brotli quality (clamped to 0–11) | -//! | `min_size` (int) | `1024` | do not compress a body smaller than this many bytes | -//! | `types` (array of strings) | text/JSON/JS/XML/SVG | a `Content-Type` is compressible when it contains any of these substrings (case-insensitive) | - -use std::io::Write; - -use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; -use flate2::Compression; -use flate2::write::GzEncoder; - -/// Encoder scratch-buffer size, matching ePHPm's own buffered brotli path. -const BROTLI_BUF: usize = 4096; -/// Brotli window (log2): 4 MiB, matching ePHPm's buffered path. -const BROTLI_LGWIN: u32 = 22; - -/// The default compressible `Content-Type` substrings — mirrors ePHPm's own -/// `is_compressible`. -const DEFAULT_TYPES: &[&str] = &["text/", "javascript", "json", "xml", "svg"]; - -/// The negotiated encoding to apply. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Encoding { - Brotli, - Gzip, -} - -impl Encoding { - fn token(self) -> &'static str { - match self { - Encoding::Brotli => "br", - Encoding::Gzip => "gzip", - } - } -} - -/// Compression policy, built once at `init`. -pub struct Compress { - brotli: bool, - gzip: bool, - level: u32, - min_size: usize, - types: Vec, -} - -/// Read an optional boolean config key with a default. -fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default), - Some(serde_json::Value::Bool(b)) => Ok(*b), - Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), - } -} - -/// Read an optional unsigned-integer config key with a default. -fn opt_u64(config: &serde_json::Value, key: &str, default: u64) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default), - Some(v) => { - v.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer, got {v}")) - } - } -} - -/// True when `content_type` matches any configured compressible substring. -fn is_compressible(content_type: &str, types: &[String]) -> bool { - let ct = content_type.to_ascii_lowercase(); - types.iter().any(|t| ct.contains(t.as_str())) -} - -/// True when the response status carries no body or a partial body and must not -/// be compressed. -fn status_forbids_compression(status: u16) -> bool { - status < 200 || status == 204 || status == 304 || status == 206 -} - -/// Parse `Accept-Encoding` and return the q-weight the client assigned `token` -/// (or `*`), or `None` when the token is not acceptable (absent, or `q=0`). -fn accepts(accept_encoding: &str, token: &str) -> bool { - let mut wildcard: Option = None; - let mut explicit: Option = None; - for part in accept_encoding.split(',') { - let mut fields = part.split(';'); - let Some(name) = fields.next().map(str::trim) else { continue }; - // q defaults to 1.0 unless a `q=` parameter says otherwise. - let mut acceptable = true; - for param in fields { - let param = param.trim(); - if let Some(q) = param.strip_prefix("q=") { - acceptable = q.trim().parse::().is_ok_and(|v| v > 0.0); - } - } - if name.eq_ignore_ascii_case(token) { - explicit = Some(acceptable); - } else if name == "*" { - wildcard = Some(acceptable); - } - } - explicit.or(wildcard).unwrap_or(false) -} - -/// Gzip-compress `data` at `level` (1–9). -fn gzip(data: &[u8], level: u32) -> Option> { - let mut encoder = GzEncoder::new(Vec::new(), Compression::new(level.clamp(1, 9))); - encoder.write_all(data).ok()?; - encoder.finish().ok() -} - -/// Brotli-compress `data` at quality derived from `level` (clamped to 0–11). -fn brotli(data: &[u8], level: u32) -> Option> { - let quality = level.min(11); - let mut out = Vec::new(); - { - let mut encoder = - brotli::CompressorWriter::new(&mut out, BROTLI_BUF, quality, BROTLI_LGWIN); - encoder.write_all(data).ok()?; - // CompressorWriter flushes the trailer on drop. - } - Some(out) -} - -impl Compress { - /// Choose the encoding to apply given the client's `Accept-Encoding`, - /// honoring the brotli-preferred order. - fn negotiate(&self, accept_encoding: &str) -> Option { - if self.brotli && accepts(accept_encoding, "br") { - return Some(Encoding::Brotli); - } - if self.gzip && accepts(accept_encoding, "gzip") { - return Some(Encoding::Gzip); - } - None - } - - /// Add `Accept-Encoding` to the response's `Vary`, preserving any existing - /// tokens and avoiding a duplicate. - fn apply_vary(resp: &mut ResponseView<'_>) { - match resp.header("Vary") { - Some(existing) => { - let already = - existing.split(',').any(|t| t.trim().eq_ignore_ascii_case("accept-encoding")); - if already { - return; - } - if existing.trim().eq_ignore_ascii_case("*") { - return; - } - resp.set_header("Vary", format!("{}, Accept-Encoding", existing.trim())); - } - None => resp.set_header("Vary", "Accept-Encoding"), - } - } -} - -impl Middleware for Compress { - fn init(config: &serde_json::Value) -> Result { - let level = opt_u64(config, "level", 5)?; - if !(1..=9).contains(&level) { - return Err(format!("`level` must be between 1 and 9, got {level}")); - } - let min_size = usize::try_from(opt_u64(config, "min_size", 1024)?) - .map_err(|_| "`min_size` is too large".to_string())?; - - let types = match config.get("types") { - None | Some(serde_json::Value::Null) => { - DEFAULT_TYPES.iter().map(|s| (*s).to_owned()).collect() - } - Some(serde_json::Value::Array(items)) => { - let mut out = Vec::with_capacity(items.len()); - for item in items { - let s = item - .as_str() - .ok_or_else(|| format!("`types` entries must be strings, got {item}"))?; - if !s.is_empty() { - out.push(s.to_ascii_lowercase()); - } - } - out - } - Some(other) => return Err(format!("`types` must be an array, got {other}")), - }; - - let brotli = opt_bool(config, "brotli", true)?; - let gzip = opt_bool(config, "gzip", true)?; - if !brotli && !gzip { - return Err("at least one of `brotli` / `gzip` must be enabled".into()); - } - - Ok(Self { brotli, gzip, level: u32::try_from(level).unwrap_or(5), min_size, types }) - } - - fn invoke(&self, _req: &Request<'_>) -> Response { - // All work happens in the response phase. - Response::cont() - } -} - -impl ResponseMiddleware for Compress { - fn invoke_response(&self, req: &Request<'_>, resp: &mut ResponseView<'_>) { - // 1. Never double-encode: if the body already carries a - // Content-Encoding (e.g. ePHPm's core compressor already ran, or PHP - // encoded it), stand down. - if resp.header("Content-Encoding").is_some_and(|v| !v.trim().is_empty()) { - return; - } - // 2. No-body / partial statuses. - if status_forbids_compression(resp.status()) { - return; - } - // 3. Range/partial responses. - if resp.header("Content-Range").is_some() { - return; - } - // 4. Explicit no-transform. - if resp - .header("Cache-Control") - .is_some_and(|v| v.to_ascii_lowercase().contains("no-transform")) - { - return; - } - // 5. Content-Type gate. - let content_type = resp.header("Content-Type").unwrap_or_default(); - if !is_compressible(&content_type, &self.types) { - return; - } - // 6. Negotiate against Accept-Encoding. - let accept = req.header("Accept-Encoding").unwrap_or(""); - let Some(encoding) = self.negotiate(accept) else { - return; - }; - // 7. Size floor. - let body = resp.body(); - if body.len() < self.min_size { - return; - } - - let compressed = match encoding { - Encoding::Brotli => brotli(body, self.level), - Encoding::Gzip => gzip(body, self.level), - }; - // 8. Only apply when it actually helped. - let Some(compressed) = compressed.filter(|c| c.len() < body.len()) else { - return; - }; - - resp.set_header("Content-Encoding", encoding.token()); - Self::apply_vary(resp); - // The host recomputes Content-Length from the replacement body. - resp.set_body(compressed); - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. - - use std::io::Read; - - use ephpm_middleware::abi::ACTION_CONTINUE; - use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; - - use super::*; - - fn init(config: serde_json::Value) -> Compress { - Compress::init(&config).expect("init") - } - - fn hdr(name: &str, value: &str) -> (String, String) { - (name.to_owned(), value.to_owned()) - } - - /// Drive the response phase; return `(headers, body)` after applying edits. - fn run( - mw: &Compress, - accept_encoding: &str, - status: u16, - resp_headers: Vec<(String, String)>, - body: &[u8], - ) -> (Vec<(String, String)>, Vec) { - let req_headers: Vec<(String, String)> = if accept_encoding.is_empty() { - vec![] - } else { - vec![hdr("Accept-Encoding", accept_encoding)] - }; - let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &req_headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - let mut rctx = ResponseCtx::new(status, resp_headers, body.to_vec()); - { - // SAFETY: `rctx` outlives the view; host_table() is 'static. - let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; - mw.invoke_response(&req, &mut view); - let (st, b, set, remove) = view.__into_parts(); - for name in remove { - rctx.remove_header(&name); - } - for (n, v) in set { - rctx.set_header(&n, &v); - } - if let Some(s) = st { - rctx.set_status(s); - } - if let Some(b) = b { - rctx.replace_body(b); - } - } - let (_status, headers, out_body) = rctx.into_parts(); - (headers, out_body) - } - - fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { - headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) - } - - fn html_body() -> Vec { - // Highly compressible, comfortably over the 1 KiB floor. - "".bytes().chain(std::iter::repeat_n(b'a', 4096)).collect() - } - - fn gunzip(data: &[u8]) -> Vec { - let mut d = flate2::read::GzDecoder::new(data); - let mut out = Vec::new(); - d.read_to_end(&mut out).expect("gunzip"); - out - } - - // ── request phase is a no-op ────────────────────────────────────────── - - #[test] - fn request_phase_continues() { - let mw = init(serde_json::Value::Null); - let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - assert_eq!(mw.invoke(&req).__action(), ACTION_CONTINUE); - } - - // ── happy paths ─────────────────────────────────────────────────────── - - #[test] - fn gzip_when_only_gzip_accepted() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, out) = run(&mw, "gzip", 200, vec![hdr("Content-Type", "text/html")], &body); - assert_eq!(get(&headers, "Content-Encoding"), Some("gzip")); - assert_eq!(get(&headers, "Vary"), Some("Accept-Encoding")); - assert!(out.len() < body.len()); - assert_eq!(gunzip(&out), body, "gzip stream must round-trip"); - } - - #[test] - fn brotli_preferred_over_gzip() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, out) = - run(&mw, "gzip, br", 200, vec![hdr("Content-Type", "text/html")], &body); - assert_eq!(get(&headers, "Content-Encoding"), Some("br")); - assert!(out.len() < body.len()); - } - - #[test] - fn gzip_used_when_brotli_disabled() { - let mw = init(serde_json::json!({ "brotli": false })); - let body = html_body(); - let (headers, _out) = - run(&mw, "gzip, br", 200, vec![hdr("Content-Type", "text/html")], &body); - assert_eq!(get(&headers, "Content-Encoding"), Some("gzip")); - } - - // ── skip conditions ─────────────────────────────────────────────────── - - #[test] - fn skips_when_already_encoded() { - // This is the core-compression-already-ran case: do NOT double-encode. - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, out) = run( - &mw, - "br", - 200, - vec![hdr("Content-Type", "text/html"), hdr("Content-Encoding", "br")], - &body, - ); - assert_eq!(get(&headers, "Content-Encoding"), Some("br")); - assert_eq!(out, body, "body must be left untouched"); - } - - #[test] - fn skips_uncompressible_content_type() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, out) = run(&mw, "br", 200, vec![hdr("Content-Type", "image/png")], &body); - assert_eq!(get(&headers, "Content-Encoding"), None); - assert_eq!(out, body); - } - - #[test] - fn skips_small_body() { - let mw = init(serde_json::Value::Null); - let body = b"tiny".to_vec(); - let (headers, out) = run(&mw, "br", 200, vec![hdr("Content-Type", "text/html")], &body); - assert_eq!(get(&headers, "Content-Encoding"), None); - assert_eq!(out, body); - } - - #[test] - fn skips_when_client_accepts_nothing() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, _out) = - run(&mw, "identity", 200, vec![hdr("Content-Type", "text/html")], &body); - assert_eq!(get(&headers, "Content-Encoding"), None); - } - - #[test] - fn skips_q0_encoding() { - let mw = init(serde_json::json!({ "brotli": false })); - let body = html_body(); - let (headers, _out) = - run(&mw, "gzip;q=0", 200, vec![hdr("Content-Type", "text/html")], &body); - assert_eq!(get(&headers, "Content-Encoding"), None); - } - - #[test] - fn skips_no_transform() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, _out) = run( - &mw, - "br", - 200, - vec![hdr("Content-Type", "text/html"), hdr("Cache-Control", "private, no-transform")], - &body, - ); - assert_eq!(get(&headers, "Content-Encoding"), None); - } - - #[test] - fn skips_304() { - let mw = init(serde_json::Value::Null); - let (headers, _out) = - run(&mw, "br", 304, vec![hdr("Content-Type", "text/html")], &html_body()); - assert_eq!(get(&headers, "Content-Encoding"), None); - } - - #[test] - fn skips_content_range() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, _out) = run( - &mw, - "br", - 206, - vec![hdr("Content-Type", "text/html"), hdr("Content-Range", "bytes 0-99/200")], - &body, - ); - assert_eq!(get(&headers, "Content-Encoding"), None); - } - - // ── Vary preservation ───────────────────────────────────────────────── - - #[test] - fn vary_is_appended_not_clobbered() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, _out) = run( - &mw, - "gzip", - 200, - vec![hdr("Content-Type", "text/html"), hdr("Vary", "Cookie")], - &body, - ); - assert_eq!(get(&headers, "Vary"), Some("Cookie, Accept-Encoding")); - } - - #[test] - fn vary_not_duplicated() { - let mw = init(serde_json::Value::Null); - let body = html_body(); - let (headers, _out) = run( - &mw, - "gzip", - 200, - vec![hdr("Content-Type", "text/html"), hdr("Vary", "Accept-Encoding")], - &body, - ); - assert_eq!(get(&headers, "Vary"), Some("Accept-Encoding")); - } - - // ── config validation ───────────────────────────────────────────────── - - #[test] - fn bad_config_fails_init() { - assert!(Compress::init(&serde_json::json!({ "level": 0 })).is_err()); - assert!(Compress::init(&serde_json::json!({ "level": 10 })).is_err()); - assert!(Compress::init(&serde_json::json!({ "brotli": false, "gzip": false })).is_err()); - assert!(Compress::init(&serde_json::json!({ "types": "text/" })).is_err()); - assert!(Compress::init(&serde_json::json!({ "min_size": -1 })).is_err()); - } - - #[test] - fn custom_types_are_honored() { - let mw = init(serde_json::json!({ "types": ["application/octet-stream"] })); - let body: Vec = std::iter::repeat_n(b'a', 4096).collect(); - let (headers, _out) = - run(&mw, "gzip", 200, vec![hdr("Content-Type", "application/octet-stream")], &body); - assert_eq!(get(&headers, "Content-Encoding"), Some("gzip")); - } -} diff --git a/crates/ephpm-middleware-modules/src/lib.rs b/crates/ephpm-middleware-modules/src/lib.rs index adf91d1..ed00a9b 100644 --- a/crates/ephpm-middleware-modules/src/lib.rs +++ b/crates/ephpm-middleware-modules/src/lib.rs @@ -16,7 +16,6 @@ //! why the implementations live here. pub mod api_key; -pub mod compression; pub mod cors; pub mod header_transform; pub mod ip_allowlist;