dwara_core/config/
transforms.rs

1//! Request/response transformation grammar (DW-028): the config shapes
2//! for header/query manipulation and JSON-pointer body transforms, the
3//! shared JSON-Pointer grammar validation and the runtime agree on, and
4//! the security-header injection policy (feature analysis 4.12 and the
5//! 5-Security "security header injection" row).
6//!
7//! This is config-contract grammar in the same sense as `net.rs` (the
8//! IP/CIDR vocabulary) and `versioning.rs` (the HTTP-date and media-type
9//! vocabularies): validation (`snapshot::validate`) and the runtime
10//! (`dataplane::transforms`) must agree on ONE parsing of these strings,
11//! so the grammar lives in `config`, the lowest consuming domain.
12//!
13//! ## The streaming contract (the issue's done-when)
14//!
15//! "Streaming is preserved unless a transform explicitly buffers." The
16//! surface splits exactly along that line:
17//!
18//! - Header and query ops, and security headers, never touch a body:
19//!   streaming responses (SSE, upgrades, chunked downloads) pass with
20//!   zero buffering, exactly as before.
21//! - A JSON body transform is the one EXPLICITLY BUFFERING transform:
22//!   it opts the route in, applies only to JSON bodies (content-type
23//!   gated), and buffers at most `max_bytes` — a hard cap enforced both
24//!   against a declared `Content-Length` and against the live stream.
25//!   Over-cap bodies fail CLOSED (413 request-side, 502 response-side),
26//!   never "transform what fit" and never silently pass through: a
27//!   skipped policy transform is a fail-open data leak in the masking
28//!   direction (DW-029 builds on this machinery).
29//!
30//! ## Pointer semantics (why strict)
31//!
32//! A `set`/`remove` whose JSON Pointer does not resolve at runtime is an
33//! ERROR, not a skip. Pointer misses are schema drift, and in the
34//! redaction direction (remove the secret field) a silent miss is
35//! exactly the leak the policy exists to prevent. Strictness costs
36//! operators a 400/502 on drifted contracts; leniency would cost them
37//! their data. The error names the offending pointer server-side only
38//! (the client envelope carries a generic message).
39//!
40//! ## Security headers vs the decoration tail
41//!
42//! Security headers are an EDGE property of every response the route
43//! emits — action responses and gateway short-circuits alike (a 401
44//! without `X-Content-Type-Options: nosniff` is a real, if minor, gap;
45//! contrast deprecation stamps, which announce API lifecycle and are
46//! deliberately absent from short-circuits). They REPLACE any
47//! upstream-sent values: the gateway is the source of truth for its
48//! edge policy, the same rule as the deprecation and `X-RateLimit-*`
49//! headers. They apply last in the response decoration tail, after
50//! operator transforms: an operator who needs per-route-exception
51//! behavior omits the field here and sets it via transforms.
52//!
53//! ## Response field masking (DW-029) — the security sharp edge
54//!
55//! [`Masking`] redacts JSON fields on the way OUT, per consumer group
56//! (the mass-assignment/data-leak guard). It reuses the buffering and
57//! pointer machinery above with ONE posture change: every gate that the
58//! DW-028 body transform treats as pass-through (an already-encoded
59//! body, a non-JSON content type) is, for masking, a REJECTION — the
60//! gateway cannot prove fields absent from bytes it cannot parse, and a
61//! skipped masking policy is exactly the leak the policy exists to
62//! prevent. Over-cap, invalid JSON, and unresolved pointers fail the
63//! same way (502), the strictness the pointer rules above already
64//! argue for. Masking runs FIRST in the response decoration tail —
65//! before the DW-028 body transform and before compression (DW-027) —
66//! so no later stage can resurrect a redacted value: once the sentinel
67//! replaces the secret, the original bytes exist nowhere in the
68//! gateway.
69
70use schemars::JsonSchema;
71use serde::{Deserialize, Serialize};
72
73use std::collections::BTreeMap;
74
75// ---------------------------------------------------------------------------
76// Config shapes
77// ---------------------------------------------------------------------------
78
79/// Route-scoped request and response transforms (DW-028, feature
80/// analysis 4.12). Absent (the default): the gateway forwards bytes and
81/// headers untouched. Presence opts the route's traffic into the
82/// declared manipulations — nothing here applies to any other route.
83///
84/// Semantics (frozen; enforcement lives in `dataplane::transforms`):
85///
86/// - REQUEST transforms run on the forward path only, after route
87///   matching, the trusted-header injection (`X-Forwarded-*`,
88///   `X-Consumer-*`), and the DW-010 path rewrite: they shape the
89///   request the upstream receives. Route matching, limits, authn, and
90///   rate limiting all evaluated the ORIGINAL request; reordering that
91///   is a policy change this block cannot make.
92/// - RESPONSE transforms run in the decoration tail, before compression
93///   (so a body transform rewrites the bytes compression then encodes,
94///   and a header transform's final `Content-Type` is what the
95///   compression eligibility check sees).
96/// - The one deliberately sharp edge: request header ops may remove or
97///   rename the gateway-injected trust headers (`X-Consumer-*`,
98///   `X-Forwarded-*`). The operator owns the upstream's contract; if
99///   their upstream must not see consumer identity, removing it here is
100///   the mechanism. Framing and hop-by-hop headers are NOT theirs to
101///   touch (validation rejects those names).
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
103#[serde(deny_unknown_fields)]
104pub struct Transforms {
105    /// Manipulations applied to the forwarded request (headers, query,
106    /// body).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub request: Option<RequestTransforms>,
109    /// Manipulations applied to the route's action responses (headers,
110    /// body).
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub response: Option<ResponseTransforms>,
113}
114
115/// Request-side transforms (DW-028).
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
117#[serde(deny_unknown_fields)]
118pub struct RequestTransforms {
119    /// Header manipulation on the forwarded request.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub headers: Option<HeaderOps>,
122    /// Query-string manipulation on the forwarded request (the path is
123    /// DW-010's `path_rewrite`; this block never touches it).
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub query: Option<QueryOps>,
126    /// Body transform (JSON) on the forwarded request — the explicitly
127    /// buffering transform, size-capped. See [`BodyTransform`].
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub body: Option<BodyTransform>,
130}
131
132/// Response-side transforms (DW-028), applied to the route's action
133/// responses (proxy, redirect, respond) — not to gateway
134/// short-circuits (limits, authn/authz, rate limits, maintenance,
135/// sheds), which describe the request, not the upstream's output.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
137#[serde(deny_unknown_fields)]
138pub struct ResponseTransforms {
139    /// Header manipulation on the route's responses.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub headers: Option<HeaderOps>,
142    /// Body transform (JSON) on the route's responses — the explicitly
143    /// buffering transform, size-capped. See [`BodyTransform`].
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub body: Option<BodyTransform>,
146}
147
148/// Header manipulation ops (DW-028). Applied in ONE deterministic
149/// order regardless of YAML key order: `set` (replace all values of the
150/// name with one value), then `add` (append one value, keeping any
151/// existing values), then `rename` (move every value of `from` to
152/// `to`), then `remove` (drop every value of the name) — so `remove`
153/// can clean up what earlier ops placed, and `rename` sees both
154/// upstream and just-set values. Maps iterate in sorted key order
155/// (BTreeMap), making multi-entry application deterministic.
156///
157/// The block runs after hop-by-hop stripping on the request side and
158/// before the gateway's own policy stamps on the response side;
159/// framing and hop-by-hop header names are rejected by validation in
160/// BOTH directions (see `dataplane::transforms` module docs for the
161/// rationale).
162#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
163#[serde(deny_unknown_fields)]
164pub struct HeaderOps {
165    /// Replace every value of the header name with this single value
166    /// (an absent name is inserted).
167    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
168    pub set: BTreeMap<String, String>,
169    /// Append one value to the header name, keeping any existing
170    /// values (the way to build multi-value headers deterministically).
171    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172    pub add: BTreeMap<String, String>,
173    /// Drop every value of these header names.
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub remove: Vec<String>,
176    /// Relabel every value of `from` onto `to` (existing `to` values
177    /// are kept; the renamed values append after them). One entry per
178    /// source name; a `from == to` entry is rejected by validation.
179    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
180    pub rename: BTreeMap<String, String>,
181}
182
183/// Query-string manipulation ops (DW-028), applied to the forwarded
184/// request's query. The original query is split into pairs WITHOUT
185/// decoding (each pair's key and value stay byte-verbatim, so untouched
186/// parameters — including their exact percent-encoding — survive); only
187/// pairs a named op touches are re-encoded (new values are
188/// percent-encoded by the gateway). Ops apply in the same deterministic
189/// order as header ops: `set`, `add`, `rename`, `remove`.
190///
191/// `set` replaces EVERY pair of the key with one pair at the END of
192/// the query (position cannot be preserved for a replaced key);
193/// `rename` keeps position, re-labeling each matching pair in place;
194/// `add` appends at the end; `remove` drops every matching pair.
195#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
196#[serde(deny_unknown_fields)]
197pub struct QueryOps {
198    /// Replace every pair of the key with this single pair (appended at
199    /// the end of the result).
200    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
201    pub set: BTreeMap<String, String>,
202    /// Append one pair at the end of the query.
203    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
204    pub add: BTreeMap<String, String>,
205    /// Drop every pair of these keys.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub remove: Vec<String>,
208    /// Re-label every pair of `from` with the new key, value verbatim,
209    /// position preserved.
210    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211    pub rename: BTreeMap<String, String>,
212}
213
214/// A body transform (DW-028). The `json` variant is the whole of v1:
215/// any other body transform family (templating, XML) would be a new
216/// variant here — the wrapper exists so adding one does not reshape
217/// the config surface.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
219#[serde(deny_unknown_fields)]
220pub struct BodyTransform {
221    /// JSON-pointer operations on JSON bodies, size-capped. See
222    /// [`JsonBodyTransform`].
223    pub json: JsonBodyTransform,
224}
225
226/// JSON-pointer body transform (DW-028): RFC 6901 pointers against a
227/// parsed JSON document, applied in listed order (each op sees the
228/// previous op's result). THIS IS THE EXPLICITLY BUFFERING TRANSFORM —
229/// the only place in the transforms surface that reads a body. Hard
230/// rules, enforced by `dataplane::transforms`:
231///
232/// - Size cap: `max_bytes` bounds the buffered body both against a
233///   declared `Content-Length` (rejected up front) and against the
234///   live stream (aborted at the frame that crosses). Over-cap fails
235///   CLOSED: 413 on the request side, 502 on the response side.
236/// - Content-type gate: the transform applies only when the body's
237///   media type is JSON (`application/json` or any
238///   `application/<*+json>`), parameters ignored. Non-JSON and empty
239///   bodies pass through untouched (streaming preserved; an empty body
240///   has nothing to transform). A body already carrying
241///   `Content-Encoding` passes through untouched: the transform does
242///   not decode (the documented pass-through of the compression
243///   policy, mirrored here).
244/// - A JSON-typed body that fails to PARSE is a client/upstream
245///   error, not a skip: 400 request-side, 502 response-side.
246/// - A pointer that does not RESOLVE fails the transform (see the
247///   module docs for why strictness is the safe default).
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
249#[serde(deny_unknown_fields)]
250pub struct JsonBodyTransform {
251    /// Maximum body size (bytes) this route will buffer to transform.
252    /// Must be >= 1 (validation); there is deliberately no upper
253    /// bound — the operator owns this route's memory budget, the same
254    /// stance as `limits.max_body_bytes`.
255    pub max_bytes: u64,
256    /// Pointer operations, applied in order.
257    pub ops: Vec<JsonOp>,
258}
259
260/// One JSON-pointer operation (DW-028). `set` writes any JSON value at
261/// the pointer (creating the final key in an object parent that must
262/// already exist; replacing an existing element in an array parent);
263/// `remove` deletes the addressed value. The ROOT pointer (`""`) is
264/// valid for `set` (whole-document replacement) and rejected for
265/// `remove` by validation (a body with no document is not a state this
266/// transform can produce).
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
268#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
269pub enum JsonOp {
270    /// Write `value` at `path` (RFC 6901 pointer).
271    Set {
272        /// RFC 6901 JSON pointer (e.g. `/meta/via`, `/items/0/id`, ``
273        /// for the whole document).
274        path: String,
275        /// Any JSON value to place at the pointer.
276        value: serde_json::Value,
277    },
278    /// Delete the value at `path` (RFC 6901 pointer, not the root).
279    Remove {
280        /// RFC 6901 JSON pointer; the root (`""`) is rejected by
281        /// validation.
282        path: String,
283    },
284}
285
286/// Security-header injection policy (DW-028, feature analysis
287/// 5-Security "security header injection"). Each field that is present
288/// injects (REPLACING any upstream-sent value — the gateway is the
289/// source of truth at its edge) exactly one standard hardening header
290/// on every response the route emits, including gateway
291/// short-circuits (401/403/413/429/503, CORS preflights): unlike
292/// deprecation stamps, these harden every byte the edge sends, and a
293/// browser parsing an error page deserves the same guarantees as one
294/// parsing a 200.
295///
296/// Not on responses with no route to consult: the framing 400 (the
297/// pre-parse sniff rejects the request before any route exists) and
298/// unrouted 404s.
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
300#[serde(deny_unknown_fields)]
301pub struct SecurityHeaders {
302    /// Emit `Strict-Transport-Security: max-age=<secs>` (RFC 6797) on
303    /// every response of the route. The presence of this field is the
304    /// policy; 0 is rejected (max-age=0 is the spec's deletion signal,
305    /// a state this policy cannot express — delete the field instead).
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub hsts_max_age_secs: Option<u64>,
308    /// Append the `includeSubDomains` directive to the HSTS header
309    /// (meaningful only with `hsts_max_age_secs`; rejected alone by
310    /// validation).
311    #[serde(default, skip_serializing_if = "is_false")]
312    pub hsts_include_subdomains: bool,
313    /// Append the `preload` directive to the HSTS header (the HSTS
314    /// preload-list signal; meaningful only with `hsts_max_age_secs`
315    /// and, per the preload list requirements, with
316    /// `hsts_include_subdomains`; that combination is enforced by
317    /// validation).
318    #[serde(default, skip_serializing_if = "is_false")]
319    pub hsts_preload: bool,
320    /// Emit `X-Content-Type-Options: nosniff` (the MIME-sniffing
321    /// off-switch; the only value the header has).
322    #[serde(default, skip_serializing_if = "is_false")]
323    pub nosniff: bool,
324    /// Emit `Content-Security-Policy: <policy>` verbatim (the operator
325    /// authors the policy; non-empty and header-representable,
326    /// validation — a trailing newline or other control byte is
327    /// rejected rather than silently not emitted).
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub content_security_policy: Option<String>,
330    /// Emit `X-Frame-Options` (legacy but still load-bearing for older
331    /// browsers). `ALLOW-FROM` is obsolete and deliberately absent.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub frame_options: Option<FrameOptions>,
334}
335
336/// The `X-Frame-Options` value (DW-028).
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
338#[serde(rename_all = "lowercase")]
339pub enum FrameOptions {
340    /// `DENY`: no framing at all.
341    Deny,
342    /// `SAMEORIGIN`: same-origin frames only.
343    Sameorigin,
344}
345
346impl FrameOptions {
347    /// The header value this option emits.
348    pub fn header_value(self) -> &'static str {
349        match self {
350            FrameOptions::Deny => "DENY",
351            FrameOptions::Sameorigin => "SAMEORIGIN",
352        }
353    }
354}
355
356/// Response field masking policy (DW-029, feature analysis 5-Security
357/// "response field masking"): RFC 6901 pointers whose values are
358/// replaced with the fixed [`MASKED_VALUE`] sentinel on the route's
359/// responses, before any other body-handling stage. The
360/// mass-assignment/data-leak guard: a field named here NEVER reaches
361/// the client, whatever the upstream put in it.
362///
363/// Semantics (frozen; enforcement lives in `dataplane::transforms`):
364///
365/// - PRECEDENCE is the deny-anywhere-wins analog for a redaction
366///   policy: the effective pointer set is the UNION of `fields` (the
367///   floor, every consumer on the route) and every `groups` entry the
368///   authenticated consumer belongs to. A group entry can only ADD
369///   pointers — there is deliberately no mechanism by which a group is
370///   EXEMPTED from the route floor (that would be a allow-anywhere
371///   escape hatch on a security policy).
372/// - The redacted value is the FIXED string [`MASKED_VALUE`], not
373///   configurable: one sentinel everywhere, so clients and audit
374///   tooling can rely on the exact shape.
375/// - Masking is explicitly buffering under `max_bytes` and FAILS
376///   CLOSED: a response that is content-encoded, not JSON, over the
377///   cap, unparseable, or misses a configured pointer answers 502 —
378///   never a passthrough of bytes the gateway could not prove clean
379///   (see the module docs; the inverse of the DW-028 transform gates).
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
381#[serde(deny_unknown_fields)]
382pub struct Masking {
383    /// Maximum response body size (bytes) this route will buffer to
384    /// mask. Must be >= 1 (validation); no upper bound, the same
385    /// operator-owns-the-memory-budget stance as the DW-028 transform
386    /// cap.
387    pub max_bytes: u64,
388    /// Pointers masked for EVERY consumer on this route (the floor).
389    /// Each must parse as an RFC 6901 pointer and not be the root
390    /// (validation); each must RESOLVE on every JSON response the
391    /// route serves, or the response fails closed (schema drift, the
392    /// same strictness as the DW-028 body transform).
393    #[serde(default, skip_serializing_if = "Vec::is_empty")]
394    pub fields: Vec<String>,
395    /// Extra pointers masked per consumer GROUP: group name -> pointers
396    /// (unioned with `fields` for members of the group; consumers in
397    /// no listed group get the floor alone). Validation checks the
398    /// group names resolve against some config consumer's `groups`
399    /// (the same store-only-group caveat as authorization group rules).
400    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
401    pub groups: BTreeMap<String, Vec<String>>,
402}
403
404/// The value a masked field is replaced with (DW-029): the JSON string
405/// `"***"` (the feature analysis row's `JSON paths -> ***`). FIXED, not
406/// configurable — the sentinel is a fixed marker the client can rely
407/// on (a value the gateway itself chose and documents) and identical
408/// on every route, so nothing about a masked response depends on
409/// per-route config a client cannot see. One ambiguity is inherent
410/// and documented: source data that is literally `"***"` at a masked
411/// position is indistinguishable from the sentinel — treat every
412/// `"***"` at a configured pointer as masked (the operator docs carry
413/// the same caveat). Operators who need a different shape on a
414/// specific route combine masking with a DW-028 response transform
415/// (which runs after masking and sees the sentinel).
416pub const MASKED_VALUE: &str = "***";
417
418/// Header names no REQUEST-side header op may touch (DW-028). Framing
419/// (`content-length`, `transfer-encoding`) is rebuilt by the forward
420/// pipeline from the actual body — an op that forced a disagreeing
421/// value would be a request-smuggling primitive aimed at the upstream,
422/// the exact class the pre-parse sniff exists to stop on the inbound
423/// side. The rest is the hop-by-hop class (RFC 9110 section 7.6.1 plus
424/// the legacy keep-alive/proxy spellings): the gateway strips and, for
425/// upgrades, re-adds `connection`/`upgrade` itself. `host` is not the
426/// operator's either — the gateway names the origin it dials (see
427/// `UpstreamHandle::send_with_hash_key`).
428pub fn is_forbidden_request_header(name: &str) -> bool {
429    let lower = name.to_ascii_lowercase();
430    matches!(
431        lower.as_str(),
432        "host"
433            | "content-length"
434            | "transfer-encoding"
435            | "connection"
436            | "keep-alive"
437            | "te"
438            | "trailer"
439            | "upgrade"
440            | "proxy-connection"
441            | "proxy-authenticate"
442            | "proxy-authorization"
443    )
444}
445
446/// Header names no RESPONSE-side header op may touch (DW-028): the
447/// framing/hop-by-hop class plus `content-encoding`, which only the
448/// compression pipeline may manage — an op that stripped it without
449/// decoding would corrupt the body, and one that added it would
450/// misdescribe bytes it did not encode.
451pub fn is_forbidden_response_header(name: &str) -> bool {
452    let lower = name.to_ascii_lowercase();
453    matches!(
454        lower.as_str(),
455        "content-length"
456            | "content-encoding"
457            | "transfer-encoding"
458            | "connection"
459            | "keep-alive"
460            | "te"
461            | "trailer"
462            | "upgrade"
463            | "proxy-connection"
464            | "proxy-authenticate"
465            | "proxy-authorization"
466    )
467}
468
469fn is_false(b: &bool) -> bool {
470    !*b
471}
472
473// ---------------------------------------------------------------------------
474// JSON Pointer grammar (RFC 6901, the shared subset)
475// ---------------------------------------------------------------------------
476
477/// A parsed RFC 6901 JSON pointer (DW-028): the compiled form the
478/// runtime applies and validation checks, so the two can never
479/// disagree on what `/items/0` means. Built once at snapshot compile;
480/// the raw config string never reaches the request path (the same
481/// precompute contract as the CORS/compression/deprecation tables).
482///
483/// The grammar is RFC 6901 with the array-index discipline of RFC 6902
484/// (`add`): a token of digits (no leading zeros, `0` alone allowed)
485/// that fits `usize` addresses an array element; every other token is
486/// an object key. A container decides which interpretation applies —
487/// on an object the token `0` is the key `"0"`; on an array the token
488/// `a` cannot resolve.
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub struct JsonPointer {
491    tokens: Vec<String>,
492    /// Precomputed array-index form per token (`None`: the token is
493    /// not index-shaped, or does not fit usize).
494    indexes: Vec<Option<usize>>,
495    /// `true` when the pointer is the whole document (`""`).
496    root: bool,
497}
498
499impl JsonPointer {
500    /// Parse an RFC 6901 pointer. `None` = malformed (does not start
501    /// with `/` unless empty, or carries a `~` escape other than `~0`
502    /// / `~1`).
503    pub fn parse(raw: &str) -> Option<JsonPointer> {
504        if raw.is_empty() {
505            return Some(JsonPointer {
506                tokens: Vec::new(),
507                indexes: Vec::new(),
508                root: true,
509            });
510        }
511        if !raw.starts_with('/') {
512            return None;
513        }
514        let mut tokens = Vec::new();
515        let mut indexes = Vec::new();
516        for raw_token in raw[1..].split('/') {
517            // Unescape ~1 -> '/' then ~0 -> '~' (RFC 6901 order); any
518            // other tilde sequence is malformed.
519            let mut token = String::new();
520            let mut chars = raw_token.chars();
521            while let Some(c) = chars.next() {
522                if c != '~' {
523                    token.push(c);
524                    continue;
525                }
526                match chars.next() {
527                    Some('0') => token.push('~'),
528                    Some('1') => token.push('/'),
529                    _ => return None,
530                }
531            }
532            indexes.push(parse_index(&token));
533            tokens.push(token);
534        }
535        Some(JsonPointer {
536            tokens,
537            indexes,
538            root: false,
539        })
540    }
541
542    /// Is this the whole-document pointer (`""`)?
543    pub fn is_root(&self) -> bool {
544        self.root
545    }
546
547    /// The reference tokens, unescaped (diagnostics and tests).
548    pub fn tokens(&self) -> &[String] {
549        &self.tokens
550    }
551
552    /// The precomputed array-index form per token, parallel to
553    /// [`tokens`](Self::tokens) (`None`: the token is not index-shaped
554    /// or does not fit usize). Exposed so the analytics body-path
555    /// dimension extractor (DW-093) can walk a JSON document without
556    /// duplicating the RFC 6901 array-index discipline.
557    pub fn indexes(&self) -> &[Option<usize>] {
558        &self.indexes
559    }
560}
561
562/// Array-index form of one reference token: digits without leading
563/// zeros (bare `0` allowed) fitting `usize`; anything else is not an
564/// index. Tokens like `01` or `-` are object keys only.
565fn parse_index(token: &str) -> Option<usize> {
566    if token.is_empty() || token.len() > 1 && token.starts_with('0') {
567        return None;
568    }
569    if !token.bytes().all(|b| b.is_ascii_digit()) {
570        return None;
571    }
572    token.parse().ok()
573}
574
575/// Is `media_type` (the part before any `;`, already lowercased) a
576/// JSON media type this gateway will body-transform (DW-028)? Exactly
577/// the `application/json` type and the structured-syntax suffix family
578/// `application/<*+json>` (an `application/vnd.acme+json` body IS a
579/// JSON body); parameters are the caller's business (stripped before
580/// this check). No other type family carries the `+json` suffix in
581/// practice, and widening to `text/json`-style one-offs buys nothing.
582pub fn is_json_media_type(media_type: &str) -> bool {
583    let Some((ty, sub)) = media_type.split_once('/') else {
584        return false;
585    };
586    ty == "application" && (sub == "json" || sub.ends_with("+json"))
587}
588
589// ---------------------------------------------------------------------------
590// Compiled form (snapshot precompute)
591// ---------------------------------------------------------------------------
592
593/// Snapshot-compiled form of a [`JsonBodyTransform`] (DW-028): every
594/// op's pointer parsed once at snapshot-compile time; the request path
595/// applies tokens, never re-parsing config strings. Validation
596/// guarantees every pointer parses, so compilation drops nothing on
597/// any publishable config; a `None`-producing parse is skipped with
598/// the same unreachable-skip contract as the CORS/compression
599/// compilations.
600#[derive(Debug, Clone, PartialEq)]
601pub struct CompiledJsonTransform {
602    max_bytes: u64,
603    ops: Vec<CompiledJsonOp>,
604}
605
606/// One compiled op.
607#[derive(Debug, Clone, PartialEq)]
608pub enum CompiledJsonOp {
609    /// Write `value` at `pointer`.
610    Set {
611        pointer: JsonPointer,
612        value: serde_json::Value,
613    },
614    /// Delete at `pointer`.
615    Remove { pointer: JsonPointer },
616}
617
618/// Why a compiled transform failed on a document (DW-028). The
619/// offending pointer rides along for the server-side log; the client
620/// envelope stays generic.
621#[derive(Debug, Clone, PartialEq, Eq)]
622pub enum JsonTransformError {
623    /// The pointer's parent path does not exist (or is not a
624    /// container), or the final token does not address an existing
625    /// member for `remove` / array-`set`.
626    Unresolved { path: String },
627    /// `remove` at the root: there is no document left to serialize
628    /// (validation rejects this; a generation-tear backstop).
629    RemoveRoot,
630}
631
632impl std::fmt::Display for JsonTransformError {
633    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634        match self {
635            JsonTransformError::Unresolved { path } => {
636                write!(f, "json pointer '{path}' does not resolve")
637            }
638            JsonTransformError::RemoveRoot => {
639                write!(f, "json pointer removes the whole document")
640            }
641        }
642    }
643}
644
645impl std::error::Error for JsonTransformError {}
646
647impl CompiledJsonTransform {
648    /// Compile a (validated) transform's op list.
649    pub fn compile(cfg: &JsonBodyTransform) -> CompiledJsonTransform {
650        let ops = cfg
651            .ops
652            .iter()
653            .filter_map(|op| match op {
654                JsonOp::Set { path, value } => {
655                    JsonPointer::parse(path).map(|pointer| CompiledJsonOp::Set {
656                        pointer,
657                        value: value.clone(),
658                    })
659                }
660                JsonOp::Remove { path } => {
661                    JsonPointer::parse(path).map(|pointer| CompiledJsonOp::Remove { pointer })
662                }
663            })
664            .collect();
665        CompiledJsonTransform {
666            max_bytes: cfg.max_bytes,
667            ops,
668        }
669    }
670
671    /// The buffering cap (bytes).
672    pub fn max_bytes(&self) -> u64 {
673        self.max_bytes
674    }
675
676    /// Apply every op to `doc`, in order. Fails on the first
677    /// unresolved pointer (see the module docs for the strictness
678    /// rationale); the document is left as the ops up to the failure
679    /// made it (the caller discards it on error).
680    pub fn apply(&self, doc: &mut serde_json::Value) -> Result<(), JsonTransformError> {
681        for op in &self.ops {
682            match op {
683                CompiledJsonOp::Set { pointer, value } => {
684                    apply_set(pointer, value, doc)?;
685                }
686                CompiledJsonOp::Remove { pointer } => {
687                    apply_remove(pointer, doc)?;
688                }
689            }
690        }
691        Ok(())
692    }
693}
694
695/// Snapshot-compiled form of a [`Masking`] policy (DW-029): every
696/// pointer parsed once at snapshot-compile time. The effective pointer
697/// set is resolved per request (the floor plus the authenticated
698/// consumer's groups — the union rule), which is why groups stay a map
699/// here rather than being flattened at compile time.
700#[derive(Debug, Clone, PartialEq)]
701pub struct CompiledMasking {
702    max_bytes: u64,
703    /// The every-consumer floor (`fields`), parsed.
704    base: Vec<JsonPointer>,
705    /// Per-group additional pointers, parsed (`groups`).
706    groups: BTreeMap<String, Vec<JsonPointer>>,
707}
708
709impl CompiledMasking {
710    /// Compile a (validated) masking policy. Validation guarantees
711    /// every pointer parses, so compilation drops nothing on any
712    /// publishable config (the same unreachable-skip contract as
713    /// [`CompiledJsonTransform::compile`]).
714    pub fn compile(cfg: &Masking) -> CompiledMasking {
715        let base = cfg
716            .fields
717            .iter()
718            .filter_map(|p| JsonPointer::parse(p))
719            .collect();
720        let groups = cfg
721            .groups
722            .iter()
723            .map(|(g, paths)| {
724                (
725                    g.clone(),
726                    paths.iter().filter_map(|p| JsonPointer::parse(p)).collect(),
727                )
728            })
729            .collect();
730        CompiledMasking {
731            max_bytes: cfg.max_bytes,
732            base,
733            groups,
734        }
735    }
736
737    /// The buffering cap (bytes).
738    pub fn max_bytes(&self) -> u64 {
739        self.max_bytes
740    }
741
742    /// Mask `doc` in place for a consumer in `consumer_groups`: every
743    /// pointer in the floor plus every listed group the consumer
744    /// belongs to is replaced with [`MASKED_VALUE`]. Returns how many
745    /// DISTINCT pointers were applied (the audit count; a pointer
746    /// listed in the floor and in one or more groups applies once).
747    /// Fails on the first pointer that does not resolve — the
748    /// strictness rule the DW-028 transform argues for, mandatory in
749    /// the redaction direction: a silent miss is the leak.
750    pub fn apply(
751        &self,
752        doc: &mut serde_json::Value,
753        consumer_groups: &[String],
754    ) -> Result<usize, JsonTransformError> {
755        // Resolve the effective set first (floor, then each matching
756        // group's extras, deduplicated): replacement is
757        // pointer-independent, so only the FIRST FAILURE depends on
758        // order — floor order, then the consumer's group-list order,
759        // both deterministic config orders.
760        let mut effective: Vec<&JsonPointer> = Vec::new();
761        for pointer in &self.base {
762            effective.push(pointer);
763        }
764        for group in consumer_groups {
765            let Some(extra) = self.groups.get(group) else {
766                continue;
767            };
768            for pointer in extra {
769                if !effective.contains(&pointer) {
770                    effective.push(pointer);
771                }
772            }
773        }
774        let count = effective.len();
775        for pointer in effective {
776            apply_mask_pointer(pointer, doc)?;
777        }
778        Ok(count)
779    }
780}
781
782/// Replace the value AT `pointer` with [`MASKED_VALUE`] — only where
783/// the pointer RESOLVES; a miss is [`JsonTransformError::Unresolved`]
784/// (masking never inserts: a field that is not there to redact means
785/// the response drifted from the contract the policy pinned).
786fn apply_mask_pointer(
787    pointer: &JsonPointer,
788    doc: &mut serde_json::Value,
789) -> Result<(), JsonTransformError> {
790    // Validation rejects the root pointer for masking; keep the walker
791    // total anyway (a generation-tear backstop, never a panic).
792    if pointer.is_root() {
793        *doc = serde_json::Value::String(MASKED_VALUE.to_string());
794        return Ok(());
795    }
796    let tokens = pointer.tokens();
797    let indexes = &pointer.indexes;
798    let (last, parent_tokens) = tokens.split_last().expect("non-root has tokens");
799    let (last_index, parent_indexes) = indexes.split_last().expect("indexes parallel tokens");
800    let path = render(pointer);
801    let parent = resolve_tokens(parent_tokens, parent_indexes, &path, doc)?;
802    let sentinel = serde_json::Value::String(MASKED_VALUE.to_string());
803    match parent {
804        serde_json::Value::Object(map) => {
805            if let Some(slot) = map.get_mut(last) {
806                *slot = sentinel;
807                Ok(())
808            } else {
809                Err(JsonTransformError::Unresolved { path })
810            }
811        }
812        serde_json::Value::Array(items) => {
813            let idx = last_index.ok_or(JsonTransformError::Unresolved { path: path.clone() })?;
814            match items.get_mut(idx) {
815                Some(slot) => {
816                    *slot = sentinel;
817                    Ok(())
818                }
819                None => Err(JsonTransformError::Unresolved { path: path.clone() }),
820            }
821        }
822        _ => Err(JsonTransformError::Unresolved { path: path.clone() }),
823    }
824}
825
826/// Resolve a token path to a mutable reference — the shared walker for
827/// both ops. `indexes` runs parallel to `tokens` (the precomputed
828/// array-index form); the two always come from one [`JsonPointer`] or
829/// one of its proper prefixes, never mixed.
830fn resolve_tokens<'a>(
831    tokens: &[String],
832    indexes: &[Option<usize>],
833    path: &str,
834    doc: &'a mut serde_json::Value,
835) -> Result<&'a mut serde_json::Value, JsonTransformError> {
836    let mut cur = doc;
837    for (i, token) in tokens.iter().enumerate() {
838        let index = indexes.get(i).copied().flatten();
839        cur = match cur {
840            serde_json::Value::Object(map) => {
841                map.get_mut(token).ok_or(JsonTransformError::Unresolved {
842                    path: path.to_string(),
843                })?
844            }
845            serde_json::Value::Array(items) => {
846                let idx = index.ok_or(JsonTransformError::Unresolved {
847                    path: path.to_string(),
848                })?;
849                items.get_mut(idx).ok_or(JsonTransformError::Unresolved {
850                    path: path.to_string(),
851                })?
852            }
853            _ => {
854                return Err(JsonTransformError::Unresolved {
855                    path: path.to_string(),
856                })
857            }
858        };
859    }
860    Ok(cur)
861}
862
863fn apply_set(
864    pointer: &JsonPointer,
865    value: &serde_json::Value,
866    doc: &mut serde_json::Value,
867) -> Result<(), JsonTransformError> {
868    if pointer.is_root() {
869        *doc = value.clone();
870        return Ok(());
871    }
872    let tokens = pointer.tokens();
873    let indexes = &pointer.indexes;
874    let (last, parent_tokens) = tokens.split_last().expect("non-root has tokens");
875    let (last_index, parent_indexes) = indexes.split_last().expect("indexes parallel tokens");
876    let path = render(pointer);
877    let parent = resolve_tokens(parent_tokens, parent_indexes, &path, doc)?;
878    match parent {
879        serde_json::Value::Object(map) => {
880            map.insert(last.clone(), value.clone());
881            Ok(())
882        }
883        serde_json::Value::Array(items) => {
884            let idx = last_index.ok_or(JsonTransformError::Unresolved { path: path.clone() })?;
885            let slot = items
886                .get_mut(idx)
887                .ok_or(JsonTransformError::Unresolved { path: path.clone() })?;
888            *slot = value.clone();
889            Ok(())
890        }
891        _ => Err(JsonTransformError::Unresolved { path: path.clone() }),
892    }
893}
894
895fn apply_remove(
896    pointer: &JsonPointer,
897    doc: &mut serde_json::Value,
898) -> Result<(), JsonTransformError> {
899    if pointer.is_root() {
900        return Err(JsonTransformError::RemoveRoot);
901    }
902    let tokens = pointer.tokens();
903    let indexes = &pointer.indexes;
904    let (last, parent_tokens) = tokens.split_last().expect("non-root has tokens");
905    let (last_index, parent_indexes) = indexes.split_last().expect("indexes parallel tokens");
906    let path = render(pointer);
907    let parent = resolve_tokens(parent_tokens, parent_indexes, &path, doc)?;
908    let existed = match parent {
909        serde_json::Value::Object(map) => map.remove(last).is_some(),
910        serde_json::Value::Array(items) => {
911            let idx = last_index.ok_or(JsonTransformError::Unresolved { path: path.clone() })?;
912            if idx < items.len() {
913                items.remove(idx);
914                true
915            } else {
916                false
917            }
918        }
919        _ => false,
920    };
921    if existed {
922        Ok(())
923    } else {
924        Err(JsonTransformError::Unresolved { path })
925    }
926}
927
928/// Human-readable form of a pointer for error paths (the original
929/// spelling is not retained; the canonical reconstruction is exact for
930/// well-formed pointers modulo escape normalization).
931fn render(pointer: &JsonPointer) -> String {
932    if pointer.is_root() {
933        return String::new();
934    }
935    let mut out = String::new();
936    for token in pointer.tokens() {
937        out.push('/');
938        // Re-escape the two RFC 6901 escapes for a faithful spelling.
939        for c in token.chars() {
940            match c {
941                '~' => out.push_str("~0"),
942                '/' => out.push_str("~1"),
943                _ => out.push(c),
944            }
945        }
946    }
947    out
948}