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}