apl_cpex/route_handler.rs
1// Location: ./crates/apl-cpex/src/route_handler.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor, Fred Araujo
5//
6// `AplRouteHandler` — synthetic plugin that drives APL evaluation when
7// cpex-core's `filter_entries_by_route` matches an annotated route. Each
8// instance is bound to ONE phase (Pre or Post) so the unified-config
9// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry
10// distinct handler logic without an in-handler hook-name discriminator.
11//
12// # Why a phase-bound handler
13//
14// The CPEX manager's annotation table is keyed on
15// `(entity_type, entity_name, scope, hook_name)`. The visitor registers
16// one handler per route per phase; the manager picks the right one based
17// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is
18// needed — the handler already knows which phase it's running.
19//
20// # Lifetime / weak manager handle
21//
22// The handler holds `Weak<PluginManager>` because the manager owns the
23// snapshot that owns the annotation that owns the handler — a strong
24// reference would create a cycle. Each `invoke` upgrades to `Arc` for
25// the duration of the call. If the upgrade fails (manager has been
26// dropped) the call returns a configuration error.
27
28use std::sync::{Arc, Weak};
29
30use async_trait::async_trait;
31use serde_json::Value;
32
33use cpex_core::cmf::MessagePayload;
34use cpex_core::context::PluginContext;
35use cpex_core::error::{PluginError, PluginViolation};
36use cpex_core::executor::ErasedResultFields;
37use cpex_core::extensions::Extensions;
38use cpex_core::hooks::PluginPayload;
39use cpex_core::manager::PluginManager;
40use cpex_core::plugin::{Plugin, PluginConfig};
41use cpex_core::registry::AnyHookHandler;
42
43use apl_cmf::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS};
44use apl_cmf::{extract_args, extract_result, BagBuilder};
45use apl_core::evaluator::Decision;
46use apl_core::plugin_decl::PluginRegistry;
47use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
48use apl_core::rules::{CompiledRoute, DenyResponse};
49use apl_core::step::PdpResolver;
50
51use crate::cmf_invoker::CmfPluginInvoker;
52use crate::delegation_invoker::DelegationPluginInvoker;
53use crate::dispatch_plan::DispatchCache;
54use crate::pdp_router::PdpRouter;
55use crate::session_store::SessionStore;
56
57/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post
58/// covers `result` + `post_invocation`. Set once at construction and never
59/// changes.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Phase {
62 Pre,
63 Post,
64}
65
66/// Synthetic plugin that drives APL evaluation for one route + one phase.
67///
68/// Implements `Plugin` (so cpex-core treats it like any other plugin —
69/// mode/capabilities/on_error come from the `PluginConfig` the visitor
70/// supplied at `annotate_route` time) and `AnyHookHandler` (so the
71/// executor dispatches into it through the normal type-erased path).
72pub struct AplRouteHandler {
73 config: PluginConfig,
74 route: Arc<CompiledRoute>,
75 phase: Phase,
76 plugin_registry: Arc<PluginRegistry>,
77 dispatch_cache: Arc<DispatchCache>,
78 session_store: Arc<dyn SessionStore>,
79 /// Weak handle to the manager so we can resolve plugin entries +
80 /// dispatch into them by-name. `Weak` avoids the
81 /// manager↔snapshot↔annotation↔handler cycle.
82 manager: Weak<PluginManager>,
83 /// PDP resolver. APL routes that don't use `pdp(...)` steps never
84 /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)`
85 /// step against an unregistered dialect returns
86 /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc.
87 /// install resolvers via [`Self::with_pdp`] or
88 /// [`Self::with_pdp_router`].
89 pdp: Arc<dyn PdpResolver>,
90}
91
92impl AplRouteHandler {
93 /// Build a handler. Visitor calls this twice per route — once for
94 /// each phase — and passes the resulting `Arc` to `annotate_route`.
95 pub fn new(
96 config: PluginConfig,
97 route: Arc<CompiledRoute>,
98 phase: Phase,
99 plugin_registry: Arc<PluginRegistry>,
100 dispatch_cache: Arc<DispatchCache>,
101 session_store: Arc<dyn SessionStore>,
102 manager: Weak<PluginManager>,
103 ) -> Self {
104 Self {
105 config,
106 route,
107 phase,
108 plugin_registry,
109 dispatch_cache,
110 session_store,
111 manager,
112 pdp: Arc::new(PdpRouter::new()),
113 }
114 }
115
116 /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs
117 /// to support multiple dialects (Cedar + OPA + NeMo) on the same
118 /// route — the router dispatches each `pdp(...)` step by dialect.
119 /// Pass a single resolver when only one dialect is in use; APL
120 /// steps for any other dialect will then return
121 /// `PdpError::NoResolver` at evaluation time.
122 pub fn with_pdp(mut self, pdp: Arc<dyn PdpResolver>) -> Self {
123 self.pdp = pdp;
124 self
125 }
126
127 /// Sugar for the common "register many resolvers" path. Builds a
128 /// [`PdpRouter`], registers each resolver into it, then installs the
129 /// router. Equivalent to constructing a `PdpRouter` by hand and
130 /// passing it to [`Self::with_pdp`].
131 pub fn with_pdp_router(
132 mut self,
133 resolvers: impl IntoIterator<Item = Arc<dyn PdpResolver>>,
134 ) -> Self {
135 let mut router = PdpRouter::new();
136 for r in resolvers {
137 router.register(r);
138 }
139 self.pdp = Arc::new(router);
140 self
141 }
142}
143
144#[async_trait]
145impl Plugin for AplRouteHandler {
146 fn config(&self) -> &PluginConfig {
147 &self.config
148 }
149}
150
151#[async_trait]
152impl AnyHookHandler for AplRouteHandler {
153 async fn invoke(
154 &self,
155 payload: &dyn PluginPayload,
156 extensions: &Extensions,
157 _ctx: &mut PluginContext,
158 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
159 // Downcast to the CMF payload — this handler only registers for
160 // cmf.* hook names, so the executor should always hand us a
161 // MessagePayload. A mismatch indicates a framework wiring bug.
162 let msg_payload = payload
163 .as_any()
164 .downcast_ref::<MessagePayload>()
165 .ok_or_else(|| {
166 Box::new(PluginError::Config {
167 message: format!(
168 "AplRouteHandler '{}': payload was not MessagePayload",
169 self.route.route_key
170 ),
171 })
172 })?;
173
174 let manager = self.manager.upgrade().ok_or_else(|| {
175 Box::new(PluginError::Config {
176 message: format!(
177 "AplRouteHandler '{}': PluginManager dropped before invoke",
178 self.route.route_key
179 ),
180 })
181 })?;
182
183 // Build (or reuse) the dispatch plan for this route. Cache keyed
184 // by `(route_key, manager.config_generation())` — if the manager
185 // has reloaded since the last invoke, the next lookup rebuilds.
186 let plan = self
187 .dispatch_cache
188 .get_or_build(&self.route, &self.plugin_registry, &manager)
189 .await;
190
191 // CmfPluginInvoker carries the request-scoped payload + extensions
192 // under interior mutability so successive plugin calls accumulate
193 // mutations. Hydration + persistence are no-ops when there's no
194 // session id (the common case for the first request in a session).
195 // Wrapped in Arc so it can be erased to `Arc<dyn PluginInvoker>`
196 // for the apl-core entry points (which take `&Arc<dyn PluginInvoker>`
197 // so `dispatch_parallel` can clone an owned, 'static reference into
198 // each spawned branch). Inherent-method calls on `CmfPluginInvoker`
199 // (e.g. `extensions_arc`, `persist_session`) deref through the Arc.
200 // Hydration loads accumulated session labels. A store failure
201 // here happens *before* any policy decision, so we fail the
202 // request closed immediately: deny with a
203 // distinguished violation rather than proceeding as if the
204 // session carried no taint. Sessionless traffic never reaches
205 // the store, so this only denies session-bearing requests.
206 let invoker = match CmfPluginInvoker::for_request(
207 Arc::clone(&manager),
208 extensions.clone(),
209 msg_payload.clone(),
210 plan,
211 Arc::clone(&self.session_store),
212 )
213 .await
214 {
215 Ok(inv) => Arc::new(inv),
216 Err(e) => {
217 tracing::error!(
218 alarm = "session_store_failure",
219 op = "load",
220 route = %self.route.route_key,
221 error = %e,
222 "session label load failed; failing request closed"
223 );
224 let mut v = PluginViolation::new(
225 "session.load_failed",
226 "session state could not be loaded",
227 );
228 decorate_denial_response(&mut v, self.route.response.as_ref());
229 return Ok(Box::new(ErasedResultFields {
230 continue_processing: false,
231 modified_payload: None,
232 modified_extensions: None,
233 violation: Some(v),
234 }));
235 },
236 };
237
238 // Build the attribute bag. APL predicates read flat keys; the
239 // BagBuilder bridges typed CPEX extensions into that namespace.
240 // `route.key` lets default/policy-bundle predicates branch on
241 // which route they're attached to.
242 let post_extensions = invoker.current_extensions().await;
243 let mut bag = BagBuilder::new()
244 .with_extensions(&post_extensions)
245 .with_route_key(&self.route.route_key)
246 .build();
247
248 // Build `RoutePayload.args` from the message. Per-content shape:
249 // * ToolCall → arguments map (JSON Object)
250 // * PromptRequest → arguments map (JSON Object)
251 // * Text-only → JSON String of concatenated text content
252 //
253 // Field pipelines operate on `args.<name>` paths. Result starts
254 // as Null on Pre (no upstream response yet); the Post phase
255 // would extract from a ToolResult / PromptResult — deferred
256 // until result-side handling lands.
257 let args_value = extract_args_from_message(&msg_payload.message);
258 let mut route_payload = match self.phase {
259 Phase::Pre => RoutePayload::new(args_value),
260 Phase::Post => {
261 // Pull the upstream result out of the message so APL
262 // `result.<field>` predicates and the `result:`
263 // pipeline have something to operate on. Falls back to
264 // `Value::Null` when the message has no ToolResult /
265 // PromptResult / Resource content (e.g. for hooks that
266 // fire on entities without a structured result).
267 let result_value = extract_result_from_message(&msg_payload.message);
268 RoutePayload::with_result(args_value, result_value)
269 },
270 };
271
272 // Flatten the call args into the bag under `args.<path>`. APL's
273 // own args pipelines read from `route_payload.args` directly,
274 // but PDP steps and predicates that reference `${args.X}` /
275 // `args.X` resolve through the bag. Mirroring the args here
276 // makes both consumers see the same vocabulary the
277 // `MessageView` exposes. (Bag-mutation via redact during the
278 // args pipeline isn't reflected back into the bag; that's fine
279 // — args predicates today read from `route_payload.args`, and
280 // the cedar substitution snapshots the pre-args view, which is
281 // what an author writing `cedar:(resource.id: ${args.X})` would
282 // expect.)
283 extract_args(&route_payload.args, &mut bag);
284 // Post phase: also project the upstream result into the bag
285 // under `result.<path>`. This is what enables predicates like
286 // `redact(result.ssn) when !perm.view_ssn` and `require(...)`
287 // gates that branch on the result. Pre phases skip this — the
288 // result is `None` by construction.
289 if matches!(self.phase, Phase::Post) {
290 if let Some(result_value) = route_payload.result.as_ref() {
291 extract_result(result_value, &mut bag);
292 }
293 }
294
295 // Slice B: real delegation invoker, sharing the CMF invoker's
296 // extensions Mutex so a `delegate(...)` step's writes to
297 // raw_credentials / delegation are visible to downstream CMF
298 // plugins and to the post phase. Routes that don't declare
299 // any `Step::Delegate` won't have entries in the plan's
300 // `delegation_entries` map; if such a route accidentally hits
301 // `delegate(...)`, the invoker returns `NotFound` and the
302 // evaluator translates it via the step's `on_error`.
303 let delegations = Arc::new(DelegationPluginInvoker::new(
304 Arc::clone(&manager),
305 invoker.extensions_arc(),
306 invoker.plan_arc(),
307 ));
308
309 // Unsized coercion: `Arc<ConcreteType>` → `Arc<dyn Trait>`. The
310 // erased forms get borrowed into `evaluate_pre`/`evaluate_post`;
311 // `dispatch_parallel` can then `Arc::clone` an owned 'static
312 // reference into each branch closure.
313 let invoker_dyn: Arc<dyn apl_core::step::PluginInvoker> = invoker.clone();
314 let delegations_dyn: Arc<dyn apl_core::step::DelegationInvoker> = delegations.clone();
315
316 let decision = match self.phase {
317 Phase::Pre => {
318 evaluate_pre(
319 &self.route,
320 &mut bag,
321 &mut route_payload,
322 &self.pdp,
323 &invoker_dyn,
324 &delegations_dyn,
325 )
326 .await
327 },
328 Phase::Post => {
329 evaluate_post(
330 &self.route,
331 &mut bag,
332 &mut route_payload,
333 &self.pdp,
334 &invoker_dyn,
335 &delegations_dyn,
336 )
337 .await
338 },
339 };
340
341 // Drain Session-scoped taints (from `taint(label, session)` /
342 // pipeline `Stage::Taint`) into `extensions.security.labels`
343 // so the existing label-diff flow inside `persist_session`
344 // picks them up. Message-scoped taints are filtered out by
345 // `apply_session_taints` — they need their own destination
346 // (see TS2). No-op when no taints emitted.
347 invoker.apply_session_taints(&decision.taints).await;
348
349 // Commit any session-scoped labels accumulated during this
350 // request. No-op when there was no session id. The result is
351 // folded into the decision below — captured here because
352 // `continue_processing`/`violation` are computed after persist.
353 let persist_result = invoker.persist_session().await;
354
355 // Surface the final mutated payload + extensions back into the
356 // PipelineResult the executor returns to the host. The host's
357 // body re-serialization picks up edits made by APL pipelines
358 // (e.g. a redact stage that rewrote args.text).
359 let final_payload = invoker.current_payload().await;
360 let final_extensions = invoker.current_extensions().await;
361
362 // Detect whether the args pipeline mutated the payload by
363 // re-extracting from the pre-eval message (msg_payload is
364 // still borrowed) and comparing against the post-eval
365 // route_payload.args. Re-extraction allocates but mirrors the
366 // surrounding pattern and avoids holding a pre-eval clone.
367 let pre_args = extract_args_from_message(&msg_payload.message);
368 // For Post phase, also detect result mutations from `result:`
369 // pipelines. Pre routes don't carry a result so this is None.
370 let pre_result = match self.phase {
371 Phase::Pre => None,
372 Phase::Post => Some(extract_result_from_message(&msg_payload.message)),
373 };
374 let modified_payload: Option<Box<dyn PluginPayload>> = if route_payload.args != pre_args {
375 // An args pipeline (Pre) rewrote a field. Fold the new
376 // args back into a fresh MessagePayload so downstream
377 // readers (the host's body re-serializer) see the
378 // change.
379 let mut updated = final_payload.clone();
380 write_args_back_to_message(&mut updated.message, &route_payload.args);
381 Some(Box::new(updated) as Box<dyn PluginPayload>)
382 } else if matches!(self.phase, Phase::Post)
383 && pre_result
384 .as_ref()
385 .zip(route_payload.result.as_ref())
386 .map(|(prev, current)| prev != current)
387 .unwrap_or(false)
388 {
389 // A `result:` pipeline rewrote a field in the upstream
390 // response. Fold the new result back into the message
391 // so the host's response body re-serializer can write
392 // it out before forwarding downstream.
393 let mut updated = final_payload.clone();
394 if let Some(result_value) = route_payload.result.as_ref() {
395 write_result_back_to_message(&mut updated.message, result_value);
396 }
397 Some(Box::new(updated) as Box<dyn PluginPayload>)
398 } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content()
399 {
400 // A `pre_invocation:` plugin mutated the message directly via
401 // `modify_payload` (not through a field pipeline). Pass
402 // the invoker's view through unchanged.
403 Some(Box::new(final_payload) as Box<dyn PluginPayload>)
404 } else {
405 None
406 };
407
408 let modified_extensions = if extensions_changed(extensions, &final_extensions) {
409 Some(final_extensions.cow_copy())
410 } else {
411 None
412 };
413
414 // Attach the route's transpiled `denyWith` to a violation at each
415 // genuine-denial site (below) via `decorate_denial_response`, rather
416 // than blanket-decorating whatever `violation` is set. This keeps the
417 // custom response off any future non-denial signal (e.g. an
418 // elicitation/retry/confirm violation) that must reach the host with
419 // its own wire shape intact.
420 let (mut continue_processing, mut violation) = match decision.decision {
421 Decision::Allow => (true, None),
422 Decision::Deny {
423 reason,
424 rule_source,
425 } => {
426 let code = if rule_source.is_empty() {
427 "policy.deny".to_string()
428 } else {
429 rule_source
430 };
431 let reason = reason.unwrap_or_else(|| "access denied".to_string());
432 let mut v = PluginViolation::new(code, reason);
433 decorate_denial_response(&mut v, self.route.response.as_ref());
434 (false, Some(v))
435 },
436 };
437
438 // Append fail-closed with merge precedence:
439 // - decision Allow + append Err → flip to Deny with a
440 // distinguished `session.persist_failed` violation.
441 // - decision Deny + append Err → keep the original policy
442 // violation (preserve attribution); the request is already
443 // denied. The append failure surfaces only as the alarm.
444 // The alarm/metric fires on every append failure regardless of
445 // decision, since the dangerous residual is a *selective*
446 // failure (append rejected while reads still succeed).
447 if let Err(e) = persist_result {
448 tracing::error!(
449 alarm = "session_store_failure",
450 op = "append",
451 route = %self.route.route_key,
452 decision_was_allow = continue_processing,
453 error = %e,
454 "session label persist failed; failing request closed"
455 );
456 if continue_processing {
457 continue_processing = false;
458 let mut v = PluginViolation::new(
459 "session.persist_failed",
460 "session state could not be persisted",
461 );
462 decorate_denial_response(&mut v, self.route.response.as_ref());
463 violation = Some(v);
464 }
465 }
466
467 Ok(Box::new(ErasedResultFields {
468 continue_processing,
469 modified_payload,
470 modified_extensions,
471 violation,
472 }))
473 }
474
475 fn hook_type_name(&self) -> &'static str {
476 // CmfHook::NAME — kept as a literal here to avoid pulling in the
477 // HookTypeDef trait just for the constant.
478 "cmf"
479 }
480}
481
482// =====================================================================
483// Helpers
484// =====================================================================
485
486/// Attach a route's transpiled `denyWith` (status/body/headers) to a
487/// denial `violation`'s `details` map so the host can render a custom HTTP
488/// denial response. Carried via `details` (not new violation fields) to
489/// keep the violation type stable. `None` response leaves the host default.
490///
491/// Call this only from genuine-denial sites — never blanket-apply it to
492/// whatever violation happens to be set, or a non-denial signal (e.g. an
493/// elicitation/retry/confirm) would get stamped with a `403`-shaped
494/// response the host would render instead of the intended wire signal.
495fn decorate_denial_response(violation: &mut PluginViolation, response: Option<&DenyResponse>) {
496 let Some(resp) = response else {
497 return;
498 };
499 if let Some(status) = resp.status {
500 violation
501 .details
502 .insert(DETAIL_HTTP_STATUS.to_string(), serde_json::json!(status));
503 }
504 if let Some(body) = &resp.body {
505 violation
506 .details
507 .insert(DETAIL_HTTP_BODY.to_string(), serde_json::json!(body));
508 }
509 if !resp.headers.is_empty() {
510 violation.details.insert(
511 DETAIL_HTTP_HEADERS.to_string(),
512 serde_json::json!(resp.headers),
513 );
514 }
515}
516
517/// Rewrite the first text part of `msg` with `new_text`. If there is no
518/// text part, append one. Mirrors what `MessagePayload`'s normal
519/// modify-path does for single-view v0.
520fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) {
521 for part in msg.content.iter_mut() {
522 if let cpex_core::cmf::ContentPart::Text { text } = part {
523 *text = new_text.to_string();
524 return;
525 }
526 }
527 msg.content.push(cpex_core::cmf::ContentPart::Text {
528 text: new_text.to_string(),
529 });
530}
531
532/// Extract `RoutePayload.args` from a CMF message. v0 maps:
533/// * First `ContentPart::ToolCall` → `arguments` map (Object)
534/// * First `ContentPart::PromptRequest` → `arguments` map (Object)
535/// * Else (text / no entity parts) → JSON String of text content
536///
537/// `args.<field>` APL paths target tool / prompt arguments directly.
538/// For text-only messages we fall back to the v0 "args = whole text"
539/// shape so `args.text` predicates keep working.
540fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value {
541 use cpex_core::cmf::ContentPart;
542 for part in &msg.content {
543 match part {
544 ContentPart::ToolCall { content } => {
545 return Value::Object(
546 content
547 .arguments
548 .iter()
549 .map(|(k, v)| (k.clone(), v.clone()))
550 .collect(),
551 );
552 },
553 ContentPart::PromptRequest { content } => {
554 return Value::Object(
555 content
556 .arguments
557 .iter()
558 .map(|(k, v)| (k.clone(), v.clone()))
559 .collect(),
560 );
561 },
562 _ => {},
563 }
564 }
565 Value::String(msg.get_text_content())
566}
567
568/// Inverse of [`extract_args_from_message`]: write `args` back into
569/// `msg`'s first ToolCall / PromptRequest argument map, or — for
570/// text payloads — into the first text part.
571///
572/// Silently no-ops when the args shape doesn't match the message
573/// content shape (e.g. operator pipeline produced a String for what
574/// was originally a ToolCall). The mismatch path is recoverable —
575/// the upstream just sees the original unmodified content rather
576/// than a malformed rewrite.
577fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) {
578 use cpex_core::cmf::ContentPart;
579 for part in msg.content.iter_mut() {
580 match part {
581 ContentPart::ToolCall { content } => {
582 if let Some(obj) = args.as_object() {
583 content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
584 }
585 return;
586 },
587 ContentPart::PromptRequest { content } => {
588 if let Some(obj) = args.as_object() {
589 content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
590 }
591 return;
592 },
593 _ => {},
594 }
595 }
596 // Fall through: no structured entity part — treat as text.
597 if let Some(text) = args.as_str() {
598 rewrite_message_text(msg, text);
599 }
600}
601
602/// Extract `RoutePayload.result` from a CMF message. Mirror of
603/// [`extract_args_from_message`] for the Post phase. v0 maps:
604/// * First `ContentPart::ToolResult` → its `content` JSON value
605/// * Else (text / no structured result part) → JSON String of text
606///
607/// `result.<field>` APL paths target the structured result directly.
608fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value {
609 use cpex_core::cmf::ContentPart;
610 for part in &msg.content {
611 if let ContentPart::ToolResult { content } = part {
612 return content.content.clone();
613 }
614 }
615 Value::String(msg.get_text_content())
616}
617
618/// Inverse of [`extract_result_from_message`]: write a mutated
619/// `result` back into the message's first `ContentPart::ToolResult.content`,
620/// or — for text-only messages — into the first text part. The praxis
621/// filter's response-body re-serializer then lifts the new content
622/// out of the ContentPart and folds it back into the JSON-RPC
623/// `result.content[*].text` payload.
624fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) {
625 use cpex_core::cmf::ContentPart;
626 for part in msg.content.iter_mut() {
627 if let ContentPart::ToolResult { content } = part {
628 content.content = result.clone();
629 return;
630 }
631 }
632 if let Some(text) = result.as_str() {
633 rewrite_message_text(msg, text);
634 }
635}
636
637/// Cheap pointer-equality check across the few mutable extension slots
638/// the executor would care about. False positives (claiming a change
639/// when there isn't one) are cheap — the executor re-validates anyway.
640fn extensions_changed(before: &Extensions, after: &Extensions) -> bool {
641 let security_changed = match (before.security.as_ref(), after.security.as_ref()) {
642 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
643 (None, None) => false,
644 _ => true,
645 };
646 let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) {
647 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
648 (None, None) => false,
649 _ => true,
650 };
651 // `delegate(...)` steps write minted tokens into
652 // `raw_credentials.delegated_tokens` via the shared Mutex —
653 // without this check, a route whose only Extensions mutation is
654 // a delegate (no security / delegation chain edit) looks
655 // unchanged, so the executor never merges the minted token back
656 // and downstream readers (our HttpFilter attaching the token to
657 // the upstream request) see nothing.
658 let raw_creds_changed = match (
659 before.raw_credentials.as_ref(),
660 after.raw_credentials.as_ref(),
661 ) {
662 (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
663 (None, None) => false,
664 _ => true,
665 };
666 security_changed || delegation_changed || raw_creds_changed
667}