dwara_core/dataplane/
plugin_dispatch.rs

1//! Request-path plugin dispatch (DW-157, epic #274).
2//!
3//! This module is the wiring between the config's `plugins`/route
4//! `plugins` lists and the live request path. For every routed request
5//! whose route references plugins, it:
6//!
7//! 1. builds the per-request [`RequestPlugins`] — a fail-closed health
8//!    gate over the generation's [`PluginLifecycle`] state, one
9//!    per-request WASM instantiation through the lifecycle's runner
10//!    (`wasm::runner::PluginRunner`), and the unified [`PluginChain`]
11//!    (native filters + WASM entries in config order, dispatched
12//!    through one [`WasmChainAdapter`]);
13//! 2. drives the chain at the four documented phase points
14//!    (`request_headers` after route resolution before authn;
15//!    `request_body` after authz/rate-limit before the action;
16//!    `response_headers` after the response arrives before masking;
17//!    `response_body` after masking before the operator transforms and
18//!    compression);
19//! 3. runs `on_done` cleanup on EVERY exit path via `Drop`.
20//!
21//! ## Fast path discipline
22//!
23//! A route with an empty `plugins` list builds nothing:
24//! [`RequestPlugins::build`] returns `Ok(None)` before any allocation,
25//! and `handle_routed` skips every phase call — the no-plugin request
26//! path is byte-identical to the pre-DW-157 path.
27//!
28//! ## Body policy
29//!
30//! Header phases always run. Body phases require a buffered body: a
31//! route whose chain declares `request_body` (or `response_body`) has
32//! that body buffered up to the route's body cap
33//! ([`plugin_body_cap`] — `routes[].limits.max_body_bytes`, default
34//! [`PLUGIN_BODY_CAP_DEFAULT`]); an over-cap body answers 500
35//! `plugin_body_too_large` (fail-closed: a plugin that declared a body
36//! phase must see the body or the request does not proceed). Bodies on
37//! routes without body-phase plugins stay streamed — zero buffering,
38//! zero cost. The response side rides the same buffering masking
39//! already establishes when masking ran; otherwise it buffers the same
40//! way. One documented exception: a STREAMING response body
41//! (`text/event-stream`, or a body with no declared length — chunked
42//! or open-ended) or a CONTENT-ENCODED response body skips the
43//! `response_body` phase (logged once, `plugin_response_body_skipped`):
44//! buffering an endless stream would stall the route, an encoded body
45//! is opaque bytes to the plugin, and the gateway never decompresses on
46//! this path (its own compression runs later, after the phase).
47//!
48//! ## Failure semantics
49//!
50//! Everything fails closed, on the AFFECTED route only:
51//!
52//! - a Crashed/Disabled/not-loaded plugin, a native name missing from
53//!   the registry, a native factory that errors while the chain is
54//!   built, or a failed per-request instantiation answers 500
55//!   `plugin_unavailable`;
56//! - a WASM trap (fuel/epoch/memory) or native filter error answers
57//!   500 `plugin_failed`;
58//! - an over-cap body answers 500 `plugin_body_too_large`;
59//! - a plugin's `send_http_response` is honored verbatim (status,
60//!   headers, body) and the upstream is never dialed.
61//!
62//! Every failure increments `dwara_plugin_failures_total{name,reason}`
63//! and logs one server-side event naming the plugin; a plugin answering
64//! the request (short-circuit OR failure) sets the access log's
65//! `plugin_short_circuit` flag.
66//!
67//! ## Header conventions
68//!
69//! The proxy-wasm convention exposes HTTP framing as pseudo-headers.
70//! Matching the host/runner tests (tests/wasm_host.rs), the request
71//! header map carries `:method` and `:path` (path INCLUDING the query
72//! string) ahead of the real headers (`host` included verbatim); the
73//! response map carries `:status`. The write-back of ORDINARY headers
74//! applies ONLY the names the chain changed (`apply_header_changes`):
75//! headers no plugin touched keep their original bytes, non-UTF-8
76//! (obs-text) values included. Pseudo-headers never enter the ordinary
77//! map — the three target pseudo-headers a chain may rewrite are
78//! captured separately ([`pseudo_header_rewrite`]) and applied when
79//! the forwarded request is built:
80//!
81//! - `:path` — the FINAL upstream target, origin-form (`/...`),
82//!   query string included (a written path without a query forwards
83//!   without one). It composes with the route's own rewrite: the
84//!   route rewrite applies first, the plugin's `:path` is the final
85//!   say, and routes are never re-matched. Only the upstream sees the
86//!   rewritten target — route matching, authn/authz, the cache key,
87//!   anomaly scoring, and the access log all evaluated the ORIGINAL
88//!   target and keep it.
89//! - `:method` — a valid method token; replaces the forwarded method.
90//! - `:authority` — overrides the forwarded `Host` header only (see
91//!   [`PluginForwardHost`]); the dialed endpoint stays the balancer's
92//!   pick.
93//!
94//! Only CHANGED values apply: writing back the value the map carried
95//! has zero effect, and REMOVING a pseudo-header is ignored (the
96//! request keeps its real method/path). An invalid changed value
97//! (empty, non-`/`-prefixed, or unparseable `:path`; a non-token
98//! `:method`; an unparseable `:authority`) fails closed: 500
99//! `plugin_failed`, metric reason `invalid_rewrite`, upstream never
100//! dialed.
101//!
102//! `:status` writes on the response map stay IGNORED (pipeline-owned)
103//! — a deliberate asymmetry with the request side, documented on
104//! [`RequestPlugins::response_headers_phase`].
105//!
106//! ## HTTP callouts: pause and resume (DW-167)
107//!
108//! A plugin phase callback may dispatch HTTP callouts
109//! (`proxy_http_call`) and return Pause. The phase method here — the
110//! ASYNC boundary — then drives the exchange, keeping the wasm runner
111//! synchronous:
112//!
113//! 1. the chain reports [`ChainOutcome::CalloutPending`] and stops at
114//!    the paused entry;
115//! 2. [`RequestPlugins::resolve_callouts`] performs each pending
116//!    exchange on `spawn_blocking` around the synchronous callout
117//!    client (`wasm::callout`), records
118//!    `dwara_plugin_callouts_total{name,outcome}` (ok, timeout,
119//!    error), and delivers the response to the instance with a
120//!    synchronous re-entry (`proxy_on_http_call_response`); the plugin
121//!    may dispatch again (bounded rounds) or decide the request. A
122//!    mid-delivery re-dispatch never abandons the rest of the drained
123//!    queue: the un-performed remainder is re-enqueued ahead of the
124//!    new dispatches (dispatch order preserved), so every dispatched
125//!    callout is eventually performed or the loop guard trips;
126//! 3. on Continue the chain resumes from the entry AFTER the paused
127//!    one with the plugin's post-callout payload.
128//!
129//! Guardrails (hard caps, no config surface): the plugin's timeout
130//! clamped to [1ms, 5s]; at most [`wasm::callout::MAX_CALLOUT_ROUNDS`]
131//! callout rounds per phase per request (exceeding fails closed, 500
132//! `plugin_failed`, metric reason `callout_loop`); callout response
133//! bodies capped at 4 MiB; redirects refused; the gateway SSRF egress
134//! filter applied at connect time against the resolved IPs.
135//!
136//! **Callout failure is a plugin failure** (a deliberate deviation
137//! from Envoy, which delivers an empty response callback on callout
138//! failure): a timed-out or refused callout answers 500
139//! `plugin_failed` (metric reason `callout_failed`) — the plugin
140//! cannot make a fail-open decision without receiving its decision
141//! input, and dwara's plugin posture is fail-closed end to end. Any
142//! COMPLETED status is delivered as data (non-2xx included); recipes
143//! wanting fail-open semantics scope them explicitly (short timeouts
144//! plus a non-callout fallback, or an extension trait instead of a
145//! plugin).
146
147use std::collections::HashMap;
148
149use bytes::Bytes;
150use http::uri::PathAndQuery;
151use http_body_util::{BodyExt as _, Full, LengthLimitError, Limited};
152use hyper::header::{HeaderMap, HeaderName, HeaderValue};
153use hyper::{Request, Response, StatusCode};
154
155use crate::config::ssrf::SsrfFilter;
156use crate::config::{Gateway, PluginConfig, PluginPhase, Route};
157use crate::dataplane::proxy::ProxyBody;
158use crate::observability::Observability;
159use crate::plugins::{ChainOutcome, LocalResponse, NativeRegistry, PluginChain};
160use crate::wasm::adapter::WasmChainAdapter;
161use crate::wasm::callout;
162use crate::wasm::lifecycle::{PluginHealth, PluginLifecycle};
163
164/// The default body cap for plugin body phases on routes without an
165/// explicit `limits.max_body_bytes` (DW-157): 1 MiB, the same bound the
166/// nano-service body cap uses. Buffering must be opt-in (the plugin
167/// declaring the phase) and size-capped (always) — this is the cap when
168/// the route configures none.
169pub const PLUGIN_BODY_CAP_DEFAULT: u64 = 1024 * 1024;
170
171/// The cap for plugin body phases (DW-157): the route's
172/// `limits.max_body_bytes` when configured, else
173/// [`PLUGIN_BODY_CAP_DEFAULT`]. The same bound governs the request and
174/// response sides (it is the only per-route body cap in the config
175/// surface). It applies even when the limits block is `dry_run` — it is
176/// a buffering bound for the plugin phase, not the 413 policy.
177pub fn plugin_body_cap(route: &Route) -> u64 {
178    route
179        .limits
180        .as_ref()
181        .and_then(|l| l.max_body_bytes)
182        .unwrap_or(PLUGIN_BODY_CAP_DEFAULT)
183}
184
185/// The fail-closed exit a plugin phase hands its caller: the response
186/// the proxy returns immediately (a plugin's local response or one of
187/// the 500 envelopes). Boxed — `Response<ProxyBody>` is large and the
188/// clippy `result_large_err` gate holds the hot-path `Result`s small.
189pub type PluginExit = Box<Response<ProxyBody>>;
190
191/// A chain's rewrite of the forwarded request target, captured at the
192/// `request_headers` phase ([`pseudo_header_rewrite`]) and applied
193/// when the upstream request is built: the phase runs early (before
194/// authn) but the rewrite shapes the UPSTREAM request only, so it
195/// rides the request extensions from the phase to the forward build
196/// (`proxy::proxy_request` removes it there — after the route's own
197/// rewrite and the query transforms, with no route re-match; see the
198/// module's "Header conventions" section). `Default` is the no-rewrite
199/// case (no pseudo-header changed); `is_noop` lets the caller skip
200/// the extension entirely on that fast path.
201#[derive(Clone, Debug, Default)]
202pub struct PluginTargetRewrite {
203    /// The validated `:path` write: origin-form, the WHOLE upstream
204    /// target including any query string.
205    pub path: Option<PathAndQuery>,
206    /// The validated `:method` write (a method token).
207    pub method: Option<hyper::Method>,
208    /// The validated `:authority` write, as the `Host` header value
209    /// the forwarded request will carry (see [`PluginForwardHost`]).
210    pub authority: Option<HeaderValue>,
211}
212
213impl PluginTargetRewrite {
214    /// Whether every field is unset — no pseudo-header was changed
215    /// (the common case; the caller skips the extension ride).
216    pub fn is_noop(&self) -> bool {
217        self.path.is_none() && self.method.is_none() && self.authority.is_none()
218    }
219}
220
221/// The `Host` override a plugin's `:authority` write produced. Rides
222/// the request extensions from the forward build to the upstream
223/// dispatch (`upstream::UpstreamHandle` owns the forwarded `Host`):
224/// the header the plugin named replaces the picked endpoint's
225/// authority there, and ONLY there — the dial target and the URI
226/// authority stay the balancer's pick (a plugin shapes the `Host`
227/// header, never which origin the gateway dials).
228#[derive(Clone, Debug)]
229pub struct PluginForwardHost(pub HeaderValue);
230
231/// The per-request plugin execution state (DW-157): the unified chain
232/// plus the fail-closed helpers that drive it. Constructed by
233/// [`RequestPlugins::build`] only on routes that reference plugins;
234/// `Drop` runs the `on_done` cleanup callback on every exit path
235/// (early short-circuits, failures, and the natural tail alike).
236pub struct RequestPlugins {
237    chain: PluginChain<WasmChainAdapter>,
238    /// The first plugin on the route declaring `request_headers` —
239    /// the deterministic attribution for a fail-closed invalid target
240    /// rewrite (the merged header map has no per-plugin author; the
241    /// same first-declarer rule the body phases attribute by).
242    request_headers_plugin: Option<String>,
243    /// The first plugin on the route declaring `request_body` — the
244    /// deterministic attribution for a request body the gateway could
245    /// not buffer for the phase (over-cap).
246    request_body_plugin: Option<String>,
247    /// The first plugin on the route declaring `response_body` — the
248    /// attribution for the response side of the same rule.
249    response_body_plugin: Option<String>,
250    /// The generation's SSRF egress filter, applied at callout connect
251    /// time (DW-167) — the same posture as webhook deliveries and
252    /// registry fetches.
253    ssrf: SsrfFilter,
254}
255
256impl Drop for RequestPlugins {
257    fn drop(&mut self) {
258        self.chain.on_done();
259    }
260}
261
262impl RequestPlugins {
263    /// Build the per-request chain for `route`. `Ok(None)` — with zero
264    /// allocations — when the route references no plugins (the fast
265    /// path). `Err(response)` is the fail-closed 500 a caller returns
266    /// immediately: a referenced plugin is Crashed/Disabled/not loaded,
267    /// a native name is missing from the registry, a native factory
268    /// errors while the chain is built, or per-request WASM
269    /// instantiation failed or did not cover every WASM plugin.
270    pub fn build(
271        lifecycle: &PluginLifecycle,
272        configs: &HashMap<String, PluginConfig>,
273        registry: &NativeRegistry,
274        route: &Route,
275        obs: &Observability,
276        rid: &str,
277        ssrf: SsrfFilter,
278    ) -> Result<Option<Self>, PluginExit> {
279        if route.plugins.is_empty() {
280            return Ok(None);
281        }
282        let mut request_headers_plugin = None;
283        let mut request_body_plugin = None;
284        let mut response_body_plugin = None;
285        // Health gate: every referenced plugin must be usable BEFORE the
286        // request proceeds (fail-closed). Validation already rejected
287        // names absent from the top-level list; a miss here is a
288        // generation tear, and it still fails closed rather than
289        // silently skipping the plugin.
290        for name in &route.plugins {
291            let Some(config) = configs.get(name) else {
292                return Err(Box::new(unavailable(obs, name, "not_loaded", route, rid)));
293            };
294            if config.phases.contains(&PluginPhase::RequestHeaders)
295                && request_headers_plugin.is_none()
296            {
297                request_headers_plugin = Some(name.clone());
298            }
299            if config.phases.contains(&PluginPhase::RequestBody) && request_body_plugin.is_none() {
300                request_body_plugin = Some(name.clone());
301            }
302            if config.phases.contains(&PluginPhase::ResponseBody) && response_body_plugin.is_none()
303            {
304                response_body_plugin = Some(name.clone());
305            }
306            if let Some(native) = &config.native {
307                if !registry.contains(native) {
308                    return Err(Box::new(unavailable(
309                        obs,
310                        name,
311                        "not_registered",
312                        route,
313                        rid,
314                    )));
315                }
316                continue;
317            }
318            // A WASM plugin (`wasm:` or the registry `source:` variant,
319            // resolved to a verified local artifact at publish — DW-165).
320            // It must have entered the lifecycle this generation: a miss
321            // is a generation tear and fails closed rather than skipping.
322            let Some(loaded) = lifecycle.get_plugin(name) else {
323                return Err(Box::new(unavailable(obs, name, "not_loaded", route, rid)));
324            };
325            match &loaded.health {
326                PluginHealth::Healthy => {}
327                PluginHealth::Crashed { error, .. } => {
328                    tracing::warn!(
329                        code = "plugin_unavailable",
330                        request_id = %rid,
331                        route = %route.name,
332                        plugin = %name,
333                        reason = %error,
334                        "route references a crashed plugin; failing closed"
335                    );
336                    obs.record_plugin_failure(name, "crashed");
337                    return Err(Box::new(unavailable_response(rid)));
338                }
339                PluginHealth::Disabled { reason } => {
340                    tracing::warn!(
341                        code = "plugin_unavailable",
342                        request_id = %rid,
343                        route = %route.name,
344                        plugin = %name,
345                        reason = %reason,
346                        "route references a disabled plugin; failing closed"
347                    );
348                    obs.record_plugin_failure(name, "disabled");
349                    return Err(Box::new(unavailable_response(rid)));
350                }
351            }
352        }
353        // Per-request instantiation through the compiled modules. All
354        // WASM plugins on the route share ONE adapter (the chain
355        // dispatches per name; see WasmChainAdapter). A native-only
356        // chain gets the EMPTY instance set (every per-name dispatch
357        // passes through — native filters dispatch through the chain's
358        // own entries). The runner can be absent only before the first
359        // successful load — every plugin then reads as not loaded
360        // above; reaching here without one is a generation tear and
361        // fails closed the same way.
362        let has_wasm = route.plugins.iter().any(|n| {
363            configs
364                .get(n)
365                .is_some_and(|c| c.wasm.is_some() || c.source.is_some())
366        });
367        let instances = if has_wasm {
368            let Some(runner) = lifecycle.runner() else {
369                return Err(Box::new(unavailable(
370                    obs,
371                    &route.plugins[0],
372                    "not_loaded",
373                    route,
374                    rid,
375                )));
376            };
377            match runner.instantiate(&route.plugins) {
378                Some(instances) => instances,
379                None => {
380                    return Err(Box::new(unavailable(
381                        obs,
382                        &route.plugins[0],
383                        "instantiate_failed",
384                        route,
385                        rid,
386                    )));
387                }
388            }
389        } else {
390            crate::wasm::runner::PluginInstances::empty()
391        };
392        // Coverage: every WASM plugin on the route must hold a
393        // per-request instance (a missing instance would silently skip
394        // a configured plugin). Native-only names never enter the
395        // runner and are exempt.
396        for name in &route.plugins {
397            let is_wasm = configs
398                .get(name)
399                .is_some_and(|c| (c.wasm.is_some() || c.source.is_some()) && c.native.is_none());
400            if is_wasm && !instances.contains(name) {
401                tracing::warn!(
402                    code = "plugin_unavailable",
403                    request_id = %rid,
404                    route = %route.name,
405                    plugin = %name,
406                    reason = "per-request instantiation failed",
407                    "route references a plugin that could not be instantiated"
408                );
409                obs.record_plugin_failure(name, "instantiate_failed");
410                return Err(Box::new(unavailable_response(rid)));
411            }
412        }
413        let (chain, create_failures) = PluginChain::new(
414            &route.plugins,
415            configs,
416            registry,
417            WasmChainAdapter::new(instances),
418        );
419        // Fail-closed native construction (DW-157): the health gate
420        // admitted the entry (the name IS registered), but its factory
421        // errored — the configured filter never runs, so the request
422        // must not proceed. Never skip a create failure the gate
423        // admitted.
424        if let Some(failure) = create_failures.first() {
425            tracing::warn!(
426                code = "plugin_unavailable",
427                request_id = %rid,
428                route = %route.name,
429                plugin = %failure.plugin,
430                reason = %failure.message,
431                "native filter construction failed; failing closed"
432            );
433            obs.record_plugin_failure(&failure.plugin, "instantiate_failed");
434            return Err(Box::new(unavailable_response(rid)));
435        }
436        Ok(Some(Self {
437            chain,
438            request_headers_plugin,
439            request_body_plugin,
440            response_body_plugin,
441            ssrf,
442        }))
443    }
444
445    /// Whether the chain has any plugin declaring `request_body`
446    /// (buffers the request body when true — see the module docs).
447    pub fn needs_request_body(&self) -> bool {
448        self.chain.has_phase(PluginPhase::RequestBody)
449    }
450
451    /// Whether the chain has any plugin declaring `response_body`
452    /// (buffers the response body when true — see the module docs).
453    pub fn needs_response_body(&self) -> bool {
454        self.chain.has_phase(PluginPhase::ResponseBody)
455    }
456
457    /// Run the `request_headers` phase: after route resolution, before
458    /// authn (the documented contract — authn sees plugin-modified
459    /// headers). `path_and_query` is the full request target (path
460    /// including any query string). On `Ok` the (possibly rewritten)
461    /// header map is in place and the returned [`PluginTargetRewrite`]
462    /// carries the chain's validated `:path`/`:method`/`:authority`
463    /// writes (see the module's "Header conventions" section) for the
464    /// caller to apply at the forward build; on `Err` the caller
465    /// returns the response immediately. Async because a plugin may
466    /// dispatch HTTP callouts here (DW-167): the phase pauses, the
467    /// callouts are performed off-thread, and the chain resumes with
468    /// the plugin's post-callout output.
469    pub async fn request_headers_phase(
470        &mut self,
471        method: &hyper::Method,
472        path_and_query: &str,
473        headers: &mut HeaderMap,
474        obs: &Observability,
475        rid: &str,
476        route: &str,
477    ) -> Result<PluginTargetRewrite, PluginExit> {
478        let mut map = Vec::with_capacity(headers.len() + 2);
479        map.push((":method".to_string(), method.as_str().to_string()));
480        map.push((":path".to_string(), path_and_query.to_string()));
481        for (k, v) in headers.iter() {
482            map.push((k.as_str().to_string(), v.to_str().unwrap_or("").to_string()));
483        }
484        // The write-back diffs against this snapshot of the input (the
485        // chain consumes `map`); see `apply_header_changes`.
486        let input = map.clone();
487        let mut outcome = self.chain.on_request_headers(map);
488        let mut callout_rounds: u32 = 0;
489        loop {
490            match outcome {
491                (ChainOutcome::Continue, out) => {
492                    apply_header_changes(headers, &input, &out);
493                    let rewrite = pseudo_header_rewrite(
494                        &input,
495                        &out,
496                        obs,
497                        rid,
498                        route,
499                        self.request_headers_plugin.as_deref(),
500                    )?;
501                    return Ok(rewrite);
502                }
503                (ChainOutcome::LocalResponse(resp), _) => {
504                    plugin_decision(rid, route);
505                    return Err(Box::new(local_response(resp, rid)));
506                }
507                (ChainOutcome::Error { plugin, message }, _) => {
508                    return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
509                }
510                (ChainOutcome::CalloutPending, _) => {
511                    if let Some(resp) = self
512                        .resolve_callouts(obs, rid, route, &mut callout_rounds)
513                        .await?
514                    {
515                        plugin_decision(rid, route);
516                        return Err(Box::new(local_response(resp, rid)));
517                    }
518                    let headers = self.chain.wasm().paused_request_headers();
519                    self.chain.wasm_mut().clear_pause();
520                    outcome = self.chain.resume_request_headers(headers);
521                }
522            }
523        }
524    }
525
526    /// Run the `request_body` phase: buffer the request body (capped —
527    /// over-cap answers 500 `plugin_body_too_large`, fail-closed), let
528    /// the chain rewrite it, and hand the action a fully-buffered
529    /// `Full<Bytes>` body with a rewritten `Content-Length`. Runs after
530    /// authz/rate-limit, before the route action (and before request
531    /// validation, which then sees the post-plugin bytes).
532    pub async fn request_body_phase<B>(
533        &mut self,
534        req: Request<B>,
535        cap: u64,
536        obs: &Observability,
537        rid: &str,
538        route: &str,
539    ) -> Result<Request<Full<Bytes>>, PluginExit>
540    where
541        B: hyper::body::Body<Data = Bytes> + Send + 'static,
542        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
543    {
544        let (mut parts, body) = req.into_parts();
545        let bytes = match Limited::new(body, cap as usize).collect().await {
546            Ok(collected) => collected.to_bytes(),
547            Err(err) => {
548                if err.downcast_ref::<LengthLimitError>().is_some() {
549                    return Err(Box::new(body_too_large(
550                        obs,
551                        rid,
552                        route,
553                        cap,
554                        self.request_body_plugin.as_deref(),
555                    )));
556                }
557                tracing::warn!(
558                    code = "plugin_body_read_failed",
559                    request_id = %rid,
560                    route = %route,
561                    error = %err,
562                    "request body could not be buffered for the plugin phase"
563                );
564                return Err(Box::new(simple(
565                    StatusCode::BAD_REQUEST,
566                    "plugin_body_read_failed",
567                    "request body could not be read",
568                    rid,
569                )));
570            }
571        };
572        let mut outcome = self.chain.on_request_body(bytes.to_vec());
573        let mut callout_rounds: u32 = 0;
574        loop {
575            match outcome {
576                (ChainOutcome::Continue, out) => {
577                    let bytes = Bytes::from(out);
578                    set_content_length(&mut parts.headers, bytes.len());
579                    return Ok(Request::from_parts(parts, Full::new(bytes)));
580                }
581                (ChainOutcome::LocalResponse(resp), _) => {
582                    plugin_decision(rid, route);
583                    return Err(Box::new(local_response(resp, rid)));
584                }
585                (ChainOutcome::Error { plugin, message }, _) => {
586                    return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
587                }
588                (ChainOutcome::CalloutPending, _) => {
589                    if let Some(resp) = self
590                        .resolve_callouts(obs, rid, route, &mut callout_rounds)
591                        .await?
592                    {
593                        plugin_decision(rid, route);
594                        return Err(Box::new(local_response(resp, rid)));
595                    }
596                    let body = self.chain.wasm().paused_request_body();
597                    self.chain.wasm_mut().clear_pause();
598                    outcome = self.chain.resume_request_body(body);
599                }
600            }
601        }
602    }
603
604    /// Run the `response_headers` phase: after the response arrives
605    /// (any action: proxy, mock, respond, redirect alike — plugins are
606    /// route-level), before masking. `:status` rides the map for
607    /// visibility; plugin writes to it are IGNORED — a deliberate
608    /// asymmetry with the request-side target rewrite: by the time
609    /// this phase runs the status is bound to the response's framing
610    /// (204/304/101 carry no body, and `Content-Length` already
611    /// matches the upstream bytes a later phase may rewrite), so a
612    /// mid-pipeline flip would desynchronize both. A plugin that
613    /// wants to DECIDE the answer has `send_http_response` at the
614    /// request phases. Async for the callout pause/resume path
615    /// (DW-167), like every phase method.
616    pub async fn response_headers_phase(
617        &mut self,
618        status: StatusCode,
619        headers: &mut HeaderMap,
620        obs: &Observability,
621        rid: &str,
622        route: &str,
623    ) -> Result<(), PluginExit> {
624        let mut map = Vec::with_capacity(headers.len() + 1);
625        map.push((":status".to_string(), status.as_u16().to_string()));
626        for (k, v) in headers.iter() {
627            map.push((k.as_str().to_string(), v.to_str().unwrap_or("").to_string()));
628        }
629        // The write-back diffs against this snapshot of the input (the
630        // chain consumes `map`); see `apply_header_changes`.
631        let input = map.clone();
632        let mut outcome = self.chain.on_response_headers(map);
633        let mut callout_rounds: u32 = 0;
634        loop {
635            match outcome {
636                (ChainOutcome::Continue, out) => {
637                    apply_header_changes(headers, &input, &out);
638                    return Ok(());
639                }
640                (ChainOutcome::LocalResponse(resp), _) => {
641                    plugin_decision(rid, route);
642                    return Err(Box::new(local_response(resp, rid)));
643                }
644                (ChainOutcome::Error { plugin, message }, _) => {
645                    return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
646                }
647                (ChainOutcome::CalloutPending, _) => {
648                    if let Some(resp) = self
649                        .resolve_callouts(obs, rid, route, &mut callout_rounds)
650                        .await?
651                    {
652                        plugin_decision(rid, route);
653                        return Err(Box::new(local_response(resp, rid)));
654                    }
655                    let headers = self.chain.wasm().paused_response_headers();
656                    self.chain.wasm_mut().clear_pause();
657                    outcome = self.chain.resume_response_headers(headers);
658                }
659            }
660        }
661    }
662
663    /// Run the `response_body` phase: after masking, before the
664    /// operator transforms and compression. Bodiless statuses
665    /// (informational, 101, 204, 304) have nothing to inspect and pass
666    /// through untouched, exactly like masking; STREAMING bodies
667    /// (`text/event-stream`, or a body of unknown length — chunked or
668    /// otherwise un-framed) and CONTENT-ENCODED bodies are skipped,
669    /// documented and logged once per response: buffering an endless
670    /// SSE stream would stall the route, and an encoded body is opaque
671    /// bytes to the plugin (dwara never decompresses on this path —
672    /// compression is applied downstream, after this phase). Every
673    /// other response on a chain with a `response_body` plugin is
674    /// buffered up to `cap` (over-cap answers 500
675    /// `plugin_body_too_large`, fail-closed), the chain may rewrite the
676    /// bytes, and the response is rebuilt with a `Full` body and a
677    /// rewritten `Content-Length`. When masking already buffered the
678    /// body (its fail-closed path) this rides the same buffered bytes.
679    pub async fn response_body_phase(
680        &mut self,
681        resp: Response<ProxyBody>,
682        cap: u64,
683        obs: &Observability,
684        rid: &str,
685        route: &str,
686    ) -> Result<Response<ProxyBody>, PluginExit> {
687        let status = resp.status();
688        if status.is_informational()
689            || status == StatusCode::SWITCHING_PROTOCOLS
690            || status == StatusCode::NO_CONTENT
691            || status == StatusCode::NOT_MODIFIED
692        {
693            return Ok(resp);
694        }
695        if let Some(reason) = plugin_body_skip_reason(&resp) {
696            tracing::debug!(
697                code = "plugin_response_body_skipped",
698                request_id = %rid,
699                route = %route,
700                reason = reason,
701                "streaming or content-encoded response; the response_body plugin phase is skipped"
702            );
703            return Ok(resp);
704        }
705        let (mut parts, body) = resp.into_parts();
706        let bytes = match Limited::new(body, cap as usize).collect().await {
707            Ok(collected) => collected.to_bytes(),
708            Err(err) => {
709                if err.downcast_ref::<LengthLimitError>().is_some() {
710                    return Err(Box::new(body_too_large(
711                        obs,
712                        rid,
713                        route,
714                        cap,
715                        self.response_body_plugin.as_deref(),
716                    )));
717                }
718                // The response stream died mid-body before the phase
719                // could run: attribute to the first response-body
720                // plugin (the plugin itself never executed — the
721                // distinct `response_stream_ended` reason keeps that
722                // clear in the metric), fail closed.
723                let name = self.response_body_plugin.as_deref().unwrap_or(route);
724                tracing::warn!(
725                    code = "plugin_failed",
726                    request_id = %rid,
727                    route = %route,
728                    plugin = name,
729                    error = %err,
730                    "response body could not be buffered for the plugin phase"
731                );
732                obs.record_plugin_failure(name, "response_stream_ended");
733                return Err(Box::new(plugin_failed_response(rid)));
734            }
735        };
736        let mut outcome = self.chain.on_response_body(bytes.to_vec());
737        let mut callout_rounds: u32 = 0;
738        loop {
739            match outcome {
740                (ChainOutcome::Continue, out) => {
741                    let bytes = Bytes::from(out);
742                    set_content_length(&mut parts.headers, bytes.len());
743                    return Ok(Response::from_parts(
744                        parts,
745                        ProxyBody::Full(Full::new(bytes)),
746                    ));
747                }
748                (ChainOutcome::LocalResponse(resp), _) => {
749                    plugin_decision(rid, route);
750                    return Err(Box::new(local_response(resp, rid)));
751                }
752                (ChainOutcome::Error { plugin, message }, _) => {
753                    return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
754                }
755                (ChainOutcome::CalloutPending, _) => {
756                    if let Some(resp) = self
757                        .resolve_callouts(obs, rid, route, &mut callout_rounds)
758                        .await?
759                    {
760                        plugin_decision(rid, route);
761                        return Err(Box::new(local_response(resp, rid)));
762                    }
763                    let body = self.chain.wasm().paused_response_body();
764                    self.chain.wasm_mut().clear_pause();
765                    outcome = self.chain.resume_response_body(body);
766                }
767            }
768        }
769    }
770
771    /// The callout pause/resume driver (DW-167): perform every pending
772    /// exchange of the paused plugin off-thread, record the outcome
773    /// metric, deliver the response with a synchronous wasmtime
774    /// re-entry, and repeat while the plugin keeps dispatching —
775    /// `rounds` counts this PHASE's exchanges (the caller owns the
776    /// counter, so the cap spans every plugin and pause in the phase).
777    /// `Ok(None)` = resolved; the caller reads the resumed payload and
778    /// resumes the chain. `Ok(Some(resp))` = the plugin decided the
779    /// request from a callout callback (`send_http_response`); the
780    /// caller answers with it. `Err` = a fail-closed exit (callout
781    /// failure, loop guard, or plugin trap).
782    async fn resolve_callouts(
783        &mut self,
784        obs: &Observability,
785        rid: &str,
786        route: &str,
787        rounds: &mut u32,
788    ) -> Result<Option<LocalResponse>, PluginExit> {
789        loop {
790            // Drain the paused plugin's queue (FIFO; usually one). An
791            // empty drain means the paused plugin finished (its last
792            // delivery resumed Continue and the payload waits for the
793            // caller) — or nothing paused at all.
794            let Some((name, requests)) = self.chain.wasm_mut().pending_callouts() else {
795                break;
796            };
797            if requests.is_empty() {
798                break;
799            }
800            let mut queue = requests.into_iter();
801            while let Some(request) = queue.next() {
802                *rounds += 1;
803                if *rounds > callout::MAX_CALLOUT_ROUNDS {
804                    return Err(Box::new(self.callout_loop_guard(obs, rid, route)));
805                }
806                let token = request.token;
807                let ssrf = self.ssrf.clone();
808                let result = match tokio::task::spawn_blocking(move || {
809                    callout::perform_callout(&request, &ssrf)
810                })
811                .await
812                {
813                    Ok(result) => result,
814                    // The blocking task itself died (panic): same
815                    // fail-closed error path as a transport failure.
816                    Err(join) => Err(callout::CalloutError::Failed(format!(
817                        "callout task failed: {join}"
818                    ))),
819                };
820                match result {
821                    Ok(resp) => {
822                        obs.record_plugin_callout(&name, "ok");
823                        match self.chain.wasm_mut().deliver_callout(token, &resp) {
824                            // Continue: deliver any remaining queued
825                            // responses; the phase resumes when the
826                            // queue drains.
827                            ChainOutcome::Continue => {}
828                            // The callback dispatched again mid-queue:
829                            // the un-performed remainder must NOT be
830                            // abandoned (its callback would never fire
831                            // while the phase resumed as if it had) —
832                            // re-enqueue it ahead of the new queue and
833                            // let the next iteration drain both.
834                            ChainOutcome::CalloutPending => {
835                                let remainder: Vec<callout::CalloutRequest> = queue.collect();
836                                if !remainder.is_empty() {
837                                    self.chain.wasm_mut().requeue_callouts(remainder);
838                                }
839                                break;
840                            }
841                            ChainOutcome::LocalResponse(resp) => return Ok(Some(resp)),
842                            ChainOutcome::Error { plugin, message } => {
843                                return Err(Box::new(plugin_error(
844                                    obs, rid, route, &plugin, &message,
845                                )));
846                            }
847                        }
848                    }
849                    Err(e) => {
850                        // Callout failure = plugin failure (see the
851                        // module docs for the deliberate deviation
852                        // from Envoy's empty-callback delivery).
853                        obs.record_plugin_callout(&name, e.outcome());
854                        tracing::warn!(
855                            code = "plugin_failed",
856                            request_id = %rid,
857                            route = %route,
858                            plugin = %name,
859                            outcome = e.outcome(),
860                            reason = %e,
861                            "plugin callout failed; failing closed"
862                        );
863                        obs.record_plugin_failure(&name, "callout_failed");
864                        return Err(Box::new(plugin_failed_response(rid)));
865                    }
866                }
867            }
868        }
869        Ok(None)
870    }
871
872    /// The fail-closed 500 for the callout loop guard: a plugin that
873    /// never stops dispatching callouts (every response callback
874    /// dispatches another) is stopped after
875    /// [`callout::MAX_CALLOUT_ROUNDS`] rounds — the metrics record
876    /// both the loop_guard callout outcome and the `callout_loop`
877    /// failure reason, attributed to the paused plugin. Missing
878    /// attribution uses the fixed "unknown" sentinel (a route NAME is
879    /// not a plugin name; the label space stays config-bounded
880    /// either way).
881    fn callout_loop_guard(
882        &mut self,
883        obs: &Observability,
884        rid: &str,
885        route: &str,
886    ) -> Response<ProxyBody> {
887        let name = self
888            .chain
889            .wasm()
890            .paused_plugin()
891            .unwrap_or("unknown")
892            .to_string();
893        tracing::warn!(
894            code = "plugin_failed",
895            request_id = %rid,
896            route = %route,
897            plugin = %name,
898            rounds = callout::MAX_CALLOUT_ROUNDS,
899            "plugin exceeded the per-phase callout round cap; failing closed"
900        );
901        obs.record_plugin_callout(&name, "loop_guard");
902        obs.record_plugin_failure(&name, "callout_loop");
903        plugin_failed_response(rid)
904    }
905}
906
907// --- Plugin status surface (DW-158) ---------------------------------------
908
909/// A plugin's observable lifecycle state for the status surface (`GET
910/// /plugins`, `dwara-cli status`, `dwara_plugin_total{state}`). The
911/// three lifecycle states come from [`PluginHealth`]; the two extra
912/// states cover plugins that are declared in the config but unusable
913/// BEFORE the lifecycle can judge them (the same conditions the
914/// request-path gate fails closed on, so the status surface never
915/// shows a plugin as serving when its routes answer 500).
916#[derive(Clone, Debug, PartialEq, Eq)]
917pub enum PluginStatusState {
918    /// Loaded and serving: a healthy WASM plugin or a registered
919    /// native filter.
920    Healthy,
921    /// The .wasm could not be read or compiled (lifecycle judgment);
922    /// routes referencing it fail closed.
923    Crashed { error: String, crash_count: u32 },
924    /// Disabled (manually or by a circuit breaker); routes referencing
925    /// it fail closed.
926    Disabled { reason: String },
927    /// Declared but not present in the running plugin runtime: a
928    /// registry `source:` artifact that has not been through a
929    /// resolution+load cycle yet, or a generation tear after a failed
930    /// engine rebuild.
931    NotLoaded,
932    /// A native filter whose registered name has no factory in the
933    /// registry; routes referencing it fail closed.
934    NotRegistered,
935}
936
937impl PluginStatusState {
938    /// The closed label vocabulary shared by `GET /plugins`, the CLI
939    /// status section, and `dwara_plugin_total{state}` — one mapping,
940    /// three surfaces, no drift.
941    pub fn label(&self) -> &'static str {
942        match self {
943            PluginStatusState::Healthy => "healthy",
944            PluginStatusState::Crashed { .. } => "crashed",
945            PluginStatusState::Disabled { .. } => "disabled",
946            PluginStatusState::NotLoaded => "not_loaded",
947            PluginStatusState::NotRegistered => "not_registered",
948        }
949    }
950}
951
952/// One plugin's status entry (DW-158): what the admin endpoint, the
953/// CLI, and the per-state gauge are all built from.
954#[derive(Clone, Debug)]
955pub struct PluginStatusEntry {
956    /// The config-declared plugin name.
957    pub name: String,
958    /// `"wasm"` (local `.wasm` path), `"registry"` (remote source), or
959    /// `"native"` (compiled-in filter).
960    pub kind: &'static str,
961    /// Where the plugin comes from: the `.wasm` path, the registry
962    /// URL, or the registered native filter name.
963    pub source: String,
964    /// SHA-256 digest of the loaded `.wasm` artifact (hot-swap
965    /// change-detection key). Empty when there is no digest to report:
966    /// native plugins (no artifact), registry plugins before their
967    /// artifact is resolved, or a `.wasm` that could not be read.
968    pub sha256: String,
969    /// The plugin's lifecycle state.
970    pub state: PluginStatusState,
971    /// Effective resource limits — configured value or the documented
972    /// default — as `(fuel, memory_mb, timeout_ms)`. `None` for native
973    /// plugins (in-process; the config schema documents `limits` as
974    /// wasm-only).
975    pub limits: Option<(u64, usize, u64)>,
976    /// The phases the plugin declared (config order).
977    pub phases: Vec<PluginPhase>,
978    /// Route names whose `plugins` list references this plugin (config
979    /// order) — the blast radius of a state change.
980    pub referenced_by: Vec<String>,
981}
982
983/// Enumerate the generation's declared plugins as status entries
984/// (DW-158), in config order. Health and digest come from the live
985/// [`PluginLifecycle`] (WASM local and registry-resolved alike,
986/// DW-165) or the [`NativeRegistry`] (native names). The dataplane
987/// serves this through `DataPlane::plugin_statuses` (the admin `GET
988/// /plugins` handler) and folds it into `dwara_plugin_total{state}`
989/// at publish time — the same enumeration, so the endpoint, the CLI,
990/// and the metric can never disagree.
991pub fn plugin_statuses(
992    lifecycle: &PluginLifecycle,
993    registry: &NativeRegistry,
994    gateway: &Gateway,
995) -> Vec<PluginStatusEntry> {
996    gateway
997        .plugins
998        .iter()
999        .map(|config| {
1000            let referenced_by: Vec<String> = gateway
1001                .routes
1002                .iter()
1003                .filter(|r| r.plugins.iter().any(|n| n == &config.name))
1004                .map(|r| r.name.clone())
1005                .collect();
1006            let (kind, source, sha256, state, limits) = if let Some(native) = &config.native {
1007                // DW-119: a compiled-in filter. The registry lookup IS
1008                // its health: an unregistered name fails closed at
1009                // request time exactly like a crashed plugin.
1010                let state = if registry.contains(native) {
1011                    PluginStatusState::Healthy
1012                } else {
1013                    PluginStatusState::NotRegistered
1014                };
1015                ("native", native.clone(), String::new(), state, None)
1016            } else {
1017                // A WASM plugin from either local origin: a `wasm:`
1018                // path or a registry `source:` (SCALE-12/DW-165)
1019                // resolved to a verified local artifact. Health and
1020                // digest come from the lifecycle, which loads/compiles
1021                // with every generation (checksum-keyed); a `source:`
1022                // that failed resolution reads Crashed with the
1023                // step-named error, its routes fail-closed.
1024                let (kind, source) = if let Some(src) = &config.source {
1025                    ("registry", src.url.clone())
1026                } else {
1027                    ("wasm", config.wasm.clone().unwrap_or_default())
1028                };
1029                let loaded = lifecycle.get_plugin(&config.name);
1030                let (sha256, state) = match &loaded {
1031                    None => (String::new(), PluginStatusState::NotLoaded),
1032                    Some(lp) => {
1033                        let state = match &lp.health {
1034                            PluginHealth::Healthy => PluginStatusState::Healthy,
1035                            PluginHealth::Crashed { error, crash_count } => {
1036                                PluginStatusState::Crashed {
1037                                    error: error.clone(),
1038                                    crash_count: *crash_count,
1039                                }
1040                            }
1041                            PluginHealth::Disabled { reason } => PluginStatusState::Disabled {
1042                                reason: reason.clone(),
1043                            },
1044                        };
1045                        (lp.checksum.clone(), state)
1046                    }
1047                };
1048                (kind, source, sha256, state, Some(effective_limits(config)))
1049            };
1050            PluginStatusEntry {
1051                name: config.name.clone(),
1052                kind,
1053                source,
1054                sha256,
1055                state,
1056                limits,
1057                phases: config.phases.clone(),
1058                referenced_by,
1059            }
1060        })
1061        .collect()
1062}
1063
1064/// A plugin's effective limits: the configured value where set, else
1065/// the documented defaults (the same defaults the runner instantiates
1066/// with, single-sourced from [`crate::wasm::PluginLimits::default`]).
1067fn effective_limits(config: &PluginConfig) -> (u64, usize, u64) {
1068    let defaults = crate::wasm::PluginLimits::default();
1069    let limits = config.limits.as_ref();
1070    (
1071        limits.and_then(|l| l.fuel).unwrap_or(defaults.fuel),
1072        limits
1073            .and_then(|l| l.memory_mb)
1074            .unwrap_or(defaults.memory_mb),
1075        limits
1076            .and_then(|l| l.timeout_ms)
1077            .unwrap_or(defaults.timeout_ms),
1078    )
1079}
1080
1081/// Group a header list by name (first-seen order), preserving each
1082/// name's value sequence so multiplicity is compared exactly.
1083/// Pseudo-headers (leading `:`) are excluded — the ordinary header map
1084/// never carries them; the request-side target pseudo-headers are
1085/// captured separately by [`pseudo_header_rewrite`].
1086fn group_header_list(list: &[(String, String)]) -> Vec<(String, Vec<&str>)> {
1087    let mut grouped: Vec<(String, Vec<&str>)> = Vec::new();
1088    for (name, value) in list {
1089        if name.starts_with(':') {
1090            continue;
1091        }
1092        match grouped.iter_mut().find(|(n, _)| n == name) {
1093            Some((_, values)) => values.push(value.as_str()),
1094            None => grouped.push((name.clone(), vec![value.as_str()])),
1095        }
1096    }
1097    grouped
1098}
1099
1100/// The pseudo-headers whose CHANGED values rewrite the forwarded
1101/// request (see the module docs): `:path` (the whole upstream target,
1102/// query string included), `:method`, and `:authority` (the forwarded
1103/// `Host`). Any other pseudo-header name stays ignored.
1104const TARGET_PSEUDO_HEADERS: [&str; 3] = [":path", ":method", ":authority"];
1105
1106/// The LAST value a header list carries for `name` (case-insensitive
1107/// — the host's replace/add hostcalls both land the newest write at or
1108/// after the previous one), or `None` when the name is absent.
1109fn last_header_value<'a>(list: &'a [(String, String)], name: &str) -> Option<&'a str> {
1110    list.iter()
1111        .rev()
1112        .find(|(k, _)| k.eq_ignore_ascii_case(name))
1113        .map(|(_, v)| v.as_str())
1114}
1115
1116/// Extract the chain's pseudo-header target rewrite from the
1117/// `request_headers` output and validate it (see the module's "Header
1118/// conventions" section). Only names whose value the chain CHANGED
1119/// participate — writing back the value the map carried is the
1120/// documented no-op, and a REMOVED pseudo-header is ignored (the
1121/// request keeps its real method/path). An invalid changed value fails
1122/// closed (500 `plugin_failed`, metric reason `invalid_rewrite`),
1123/// attributed to the first plugin declaring the phase (`plugin`; the
1124/// merged chain output has no per-plugin author — the same
1125/// deterministic first-declarer attribution the body phases use).
1126/// The invalid value itself is never logged: a `:path` carries the
1127/// query string, which logs must not see.
1128fn pseudo_header_rewrite(
1129    input: &[(String, String)],
1130    output: &[(String, String)],
1131    obs: &Observability,
1132    rid: &str,
1133    route: &str,
1134    plugin: Option<&str>,
1135) -> Result<PluginTargetRewrite, PluginExit> {
1136    let mut rewrite = PluginTargetRewrite::default();
1137    for name in TARGET_PSEUDO_HEADERS {
1138        // Absent from the output (never written, or removed): no
1139        // rewrite from this name.
1140        let Some(new) = last_header_value(output, name) else {
1141            continue;
1142        };
1143        // Identical to the input value: zero effect.
1144        if last_header_value(input, name) == Some(new) {
1145            continue;
1146        }
1147        match name {
1148            // Origin-form only: non-empty, leading '/', parseable as a
1149            // path-and-query (rejects bad characters and fragments).
1150            // An absolute-form target would move the dial target — the
1151            // upstream pick is the balancer's, never the plugin's.
1152            ":path" => match if new.is_empty() || !new.starts_with('/') {
1153                None
1154            } else {
1155                new.parse::<PathAndQuery>().ok()
1156            } {
1157                Some(target) => rewrite.path = Some(target),
1158                None => {
1159                    return Err(Box::new(invalid_rewrite(obs, rid, route, plugin, name)));
1160                }
1161            },
1162            ":method" => match new.parse::<hyper::Method>() {
1163                Ok(method) => rewrite.method = Some(method),
1164                Err(_) => {
1165                    return Err(Box::new(invalid_rewrite(obs, rid, route, plugin, name)));
1166                }
1167            },
1168            // The authority grammar, converted to the header value the
1169            // forward will carry: either parse failing (empty,
1170            // userinfo, bad bytes) is an unusable Host — fail closed.
1171            ":authority" => match new
1172                .parse::<http::uri::Authority>()
1173                .ok()
1174                .and_then(|a| HeaderValue::from_str(a.as_str()).ok())
1175            {
1176                Some(host) => rewrite.authority = Some(host),
1177                None => {
1178                    return Err(Box::new(invalid_rewrite(obs, rid, route, plugin, name)));
1179                }
1180            },
1181            _ => unreachable!("TARGET_PSEUDO_HEADERS enumerates every match arm"),
1182        }
1183    }
1184    Ok(rewrite)
1185}
1186
1187/// The fail-closed 500 for a plugin's invalid target rewrite (empty,
1188/// non-`/`-prefixed, or unparseable `:path`; a non-token `:method`;
1189/// an unparseable `:authority`): one failure metric (`invalid_rewrite`
1190/// — attributed to the first `request_headers` plugin, falling back to
1191/// the route name exactly like the body-cap attribution), one
1192/// server-side log naming the pseudo-header but never the value, and
1193/// the generic envelope. The request does not proceed to the upstream.
1194fn invalid_rewrite(
1195    obs: &Observability,
1196    rid: &str,
1197    route: &str,
1198    plugin: Option<&str>,
1199    name: &str,
1200) -> Response<ProxyBody> {
1201    let attribution = plugin.unwrap_or(route);
1202    tracing::warn!(
1203        code = "plugin_failed",
1204        request_id = %rid,
1205        route = %route,
1206        plugin = %attribution,
1207        pseudo_header = name,
1208        "plugin wrote an invalid target rewrite; failing closed"
1209    );
1210    obs.record_plugin_failure(attribution, "invalid_rewrite");
1211    plugin_failed_response(rid)
1212}
1213
1214/// Why a response's `response_body` plugin phase is skipped (DW-157):
1215/// the body is streaming (SSE or un-framed — buffering it would stall
1216/// the route) or content-encoded (opaque bytes to the plugin; dwara
1217/// never decompresses on this path). `None` = bufferable.
1218fn plugin_body_skip_reason(resp: &Response<ProxyBody>) -> Option<&'static str> {
1219    let headers = resp.headers();
1220    if headers.contains_key(hyper::header::CONTENT_ENCODING) {
1221        return Some("content_encoded");
1222    }
1223    let sse = headers
1224        .get(hyper::header::CONTENT_TYPE)
1225        .and_then(|v| v.to_str().ok())
1226        .is_some_and(|ct| {
1227            ct.split(';')
1228                .next()
1229                .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/event-stream"))
1230        });
1231    if sse {
1232        return Some("streaming");
1233    }
1234    if !headers.contains_key(hyper::header::CONTENT_LENGTH) {
1235        // No declared length: chunked or an open-ended stream. The
1236        // phase needs the WHOLE body up front, which is exactly what
1237        // these bodies never promise.
1238        return Some("streaming");
1239    }
1240    None
1241}
1242
1243/// A plugin DECIDED the request's answer (`send_http_response` or a
1244/// native LocalResponse): not a failure — log at info for the audit
1245/// trail; the access-log `plugin_short_circuit` flag is set by the
1246/// caller (the proxy).
1247fn plugin_decision(rid: &str, route: &str) {
1248    tracing::info!(
1249        code = "plugin_short_circuit",
1250        request_id = %rid,
1251        route = %route,
1252        "plugin answered the request locally; the upstream was not dialed"
1253    );
1254}
1255
1256/// A plugin failed (WASM trap or native filter error): fail-closed 500
1257/// `plugin_failed`, one failure metric, one server-side log naming the
1258/// plugin. The trap detail never reaches the client.
1259fn plugin_error(
1260    obs: &Observability,
1261    rid: &str,
1262    route: &str,
1263    plugin: &str,
1264    message: &str,
1265) -> Response<ProxyBody> {
1266    tracing::warn!(
1267        code = "plugin_failed",
1268        request_id = %rid,
1269        route = %route,
1270        plugin = %plugin,
1271        reason = %message,
1272        "plugin trapped or errored; failing closed"
1273    );
1274    obs.record_plugin_failure(plugin, "trap");
1275    plugin_failed_response(rid)
1276}
1277
1278fn plugin_failed_response(rid: &str) -> Response<ProxyBody> {
1279    simple(
1280        StatusCode::INTERNAL_SERVER_ERROR,
1281        "plugin_failed",
1282        "a plugin on this route failed",
1283        rid,
1284    )
1285}
1286
1287fn unavailable_response(rid: &str) -> Response<ProxyBody> {
1288    simple(
1289        StatusCode::INTERNAL_SERVER_ERROR,
1290        "plugin_unavailable",
1291        "a plugin on this route is unavailable",
1292        rid,
1293    )
1294}
1295
1296/// The fail-closed 500 for an unusable plugin (missing, crashed,
1297/// disabled, unregistered native, failed instantiation): metric +
1298/// server-side log (the specific reason) + the generic envelope.
1299fn unavailable(
1300    obs: &Observability,
1301    name: &str,
1302    reason: &'static str,
1303    route: &Route,
1304    rid: &str,
1305) -> Response<ProxyBody> {
1306    tracing::warn!(
1307        code = "plugin_unavailable",
1308        request_id = %rid,
1309        route = %route.name,
1310        plugin = %name,
1311        reason = reason,
1312        "route references an unusable plugin; failing closed"
1313    );
1314    obs.record_plugin_failure(name, reason);
1315    unavailable_response(rid)
1316}
1317
1318/// The fail-closed 500 for a body over the plugin buffering cap.
1319/// `plugin` is the first plugin declaring the phase (deterministic
1320/// attribution; falls back to the route name — the phase, not one
1321/// plugin, is what could not be satisfied — keeping the metric's label
1322/// space config-bounded either way).
1323fn body_too_large(
1324    obs: &Observability,
1325    rid: &str,
1326    route: &str,
1327    cap: u64,
1328    plugin: Option<&str>,
1329) -> Response<ProxyBody> {
1330    let name = plugin.unwrap_or(route);
1331    tracing::warn!(
1332        code = "plugin_body_too_large",
1333        request_id = %rid,
1334        route = %route,
1335        plugin = name,
1336        cap_bytes = cap,
1337        "body exceeds the plugin buffering cap; failing closed"
1338    );
1339    obs.record_plugin_failure(name, "body_too_large");
1340    simple(
1341        StatusCode::INTERNAL_SERVER_ERROR,
1342        "plugin_body_too_large",
1343        &format!("body exceeds the plugin buffering cap of {cap} bytes"),
1344        rid,
1345    )
1346}
1347
1348/// Build the response for a plugin's local response
1349/// (`proxy_send_http_response` / native LocalResponse): the plugin's
1350/// status, headers, and body verbatim; a `content-type` default of
1351/// `application/json` when it set none (mirroring the nano-service
1352/// response builder).
1353fn local_response(resp: LocalResponse, rid: &str) -> Response<ProxyBody> {
1354    let mut builder = Response::builder()
1355        .status(StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR));
1356    let has_content_type = resp
1357        .headers
1358        .iter()
1359        .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
1360    if !has_content_type && !resp.body.is_empty() {
1361        builder = builder.header(hyper::header::CONTENT_TYPE, "application/json");
1362    }
1363    for (name, value) in &resp.headers {
1364        if let (Ok(n), Ok(v)) = (
1365            HeaderName::from_bytes(name.as_bytes()),
1366            HeaderValue::from_str(value),
1367        ) {
1368            builder = builder.header(n, v);
1369        }
1370    }
1371    match builder.body(ProxyBody::Full(Full::new(Bytes::from(resp.body)))) {
1372        Ok(resp) => resp,
1373        Err(_) => plugin_failed_response(rid),
1374    }
1375}
1376
1377/// Write a plugin phase's header output back into the live
1378/// [`HeaderMap`], applying ONLY what the chain actually changed
1379/// (DW-157). The `(String, String)` list the sandbox sees cannot
1380/// represent non-UTF-8 header values (legal obs-text), so a
1381/// clear-and-rebuild would blank them. Instead:
1382///
1383/// - when the chain returned its input unchanged (the common case — no
1384///   plugin in the phase, or pure pass-through) the map is not touched
1385///   at all;
1386/// - otherwise the lists are diffed per name: only names whose VALUE
1387///   LIST the chain changed (added, removed, reordered, or rewritten)
1388///   are rewritten in the map; every other header keeps its original
1389///   bytes, non-UTF-8 values included.
1390///
1391/// Pseudo-headers (leading `:`) are excluded from both sides — the
1392/// ordinary header map never carries them (the request-side target
1393/// pseudo-headers are applied separately; see
1394/// [`pseudo_header_rewrite`]). Unparseable names/values a changed name
1395/// produced are skipped (the sandbox cannot crash the gateway with a
1396/// bad header).
1397fn apply_header_changes(
1398    headers: &mut HeaderMap,
1399    input: &[(String, String)],
1400    output: &[(String, String)],
1401) {
1402    if input == output {
1403        return;
1404    }
1405    let before = group_header_list(input);
1406    let after = group_header_list(output);
1407    for (name, new_values) in &after {
1408        let unchanged = before
1409            .iter()
1410            .find(|(n, _)| n == name)
1411            .is_some_and(|(_, old_values)| old_values == new_values);
1412        if unchanged {
1413            continue;
1414        }
1415        // The chain changed this name's value list: rewrite it in the
1416        // map. `remove` drops every value of the name; the plugin's
1417        // list is the new truth for it.
1418        headers.remove(name.as_str());
1419        for value in new_values {
1420            if let (Ok(n), Ok(v)) = (
1421                HeaderName::from_bytes(name.as_bytes()),
1422                HeaderValue::from_str(value),
1423            ) {
1424                headers.append(n, v);
1425            }
1426        }
1427    }
1428    // Names the chain REMOVED (present before, absent after).
1429    for (name, _) in &before {
1430        if !after.iter().any(|(n, _)| n == name) {
1431            headers.remove(name.as_str());
1432        }
1433    }
1434}
1435
1436/// Set `Content-Length` to the (rewritten) body length after a body
1437/// phase replaces the body with buffered bytes. Framing is exact: the
1438/// buffered bytes ARE the body now.
1439fn set_content_length(headers: &mut HeaderMap, len: usize) {
1440    headers.remove(hyper::header::TRANSFER_ENCODING);
1441    if let Ok(v) = HeaderValue::from_str(&len.to_string()) {
1442        headers.insert(hyper::header::CONTENT_LENGTH, v);
1443    }
1444}
1445
1446/// The uniform gateway error envelope (a local copy of the proxy's
1447/// `simple` responder: this module builds its own responses and the
1448/// proxy's helper is private to that module).
1449fn simple(status: StatusCode, code: &str, msg: &str, rid: &str) -> Response<ProxyBody> {
1450    Response::builder()
1451        .status(status)
1452        .header(hyper::header::CONTENT_TYPE, "application/json")
1453        .body(ProxyBody::Full(Full::new(
1454            crate::observability::envelope_body(code, msg, rid),
1455        )))
1456        .expect("static error body is valid")
1457}