Skip to main content

gaze_proxy/
adapter.rs

1use std::fmt;
2use std::time::Duration;
3
4use http::Method;
5use serde_json::Value;
6use url::Url;
7
8use crate::codec::{BodyCodec, CodecLimits, WireFormat};
9
10/// Transport protocol selected by a reviewed adapter contract.
11#[derive(Clone, Copy)]
12pub enum ProtocolContract<'a> {
13    /// Preserve the pre-codec JSON/SSE surface traversal path.
14    LegacySurfaces,
15    /// Transform complete bodies through the borrowed codec.
16    Codec(&'a dyn BodyCodec),
17}
18
19impl ProtocolContract<'_> {
20    /// Returns the selected codec, if this is a codec-backed contract.
21    #[must_use]
22    pub const fn codec(&self) -> Option<&dyn BodyCodec> {
23        match self {
24            Self::LegacySurfaces => None,
25            Self::Codec(codec) => Some(*codec),
26        }
27    }
28
29    /// Reports whether this contract preserves the legacy traversal path.
30    #[must_use]
31    pub const fn is_legacy(&self) -> bool {
32        matches!(self, Self::LegacySurfaces)
33    }
34}
35
36impl fmt::Debug for ProtocolContract<'_> {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::LegacySurfaces => formatter.write_str("LegacySurfaces"),
40            Self::Codec(_) => formatter.debug_tuple("Codec").field(&"BodyCodec").finish(),
41        }
42    }
43}
44
45/// Session lifecycle required by an adapter protocol.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SessionPolicy {
48    /// A request owns an isolated session that is discarded after its response.
49    EphemeralSingleRequest,
50    /// An opaque header selects a bounded continuity registry.
51    OpaqueHeaderContinuity(SessionRegistryConfig),
52}
53
54/// Closed validation failure for [`SessionRegistryConfig`].
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56pub enum SessionRegistryConfigError {
57    InvalidMaxActiveSessions,
58    InvalidSessionTtl,
59    InvalidMaxTombstones,
60    InvalidMaxTombstoneBytes,
61    InvalidTombstoneTtl,
62    NotALowering,
63}
64
65impl fmt::Display for SessionRegistryConfigError {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        formatter.write_str(match self {
68            Self::InvalidMaxActiveSessions => "invalid maximum active session count",
69            Self::InvalidSessionTtl => "invalid session TTL",
70            Self::InvalidMaxTombstones => "invalid maximum tombstone count",
71            Self::InvalidMaxTombstoneBytes => "invalid maximum tombstone byte count",
72            Self::InvalidTombstoneTtl => "invalid tombstone TTL",
73            Self::NotALowering => "registry limit may only be lowered",
74        })
75    }
76}
77
78impl std::error::Error for SessionRegistryConfigError {}
79
80/// Finite, validated bounds for an opaque-session continuity registry.
81///
82/// Fields are deliberately private so callers cannot bypass validation.
83///
84/// ```compile_fail
85/// use gaze_proxy::SessionRegistryConfig;
86///
87/// let _ = SessionRegistryConfig { max_active_sessions: 1 };
88/// ```
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct SessionRegistryConfig {
91    max_active_sessions: usize,
92    session_ttl: Duration,
93    max_tombstones: usize,
94    max_tombstone_bytes: usize,
95    tombstone_ttl: Duration,
96}
97
98impl Default for SessionRegistryConfig {
99    fn default() -> Self {
100        Self {
101            max_active_sessions: 4_096,
102            session_ttl: Duration::from_secs(30 * 60),
103            max_tombstones: 4_096,
104            max_tombstone_bytes: 1024 * 1024,
105            tombstone_ttl: Duration::from_secs(30 * 60),
106        }
107    }
108}
109
110impl SessionRegistryConfig {
111    pub const HARD_MAX_ACTIVE_SESSIONS: usize = 65_536;
112    pub const HARD_MAX_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
113    pub const HARD_MAX_TOMBSTONES: usize = 65_536;
114    pub const HARD_MAX_TOMBSTONE_BYTES: usize = 64 * 1024 * 1024;
115    pub const HARD_MAX_TOMBSTONE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
116
117    /// Constructs a registry configuration after validating every finite bound.
118    pub fn try_new(
119        max_active_sessions: usize,
120        session_ttl: Duration,
121        max_tombstones: usize,
122        max_tombstone_bytes: usize,
123        tombstone_ttl: Duration,
124    ) -> Result<Self, SessionRegistryConfigError> {
125        if max_active_sessions == 0 || max_active_sessions > Self::HARD_MAX_ACTIVE_SESSIONS {
126            return Err(SessionRegistryConfigError::InvalidMaxActiveSessions);
127        }
128        if session_ttl.is_zero() || session_ttl > Self::HARD_MAX_SESSION_TTL {
129            return Err(SessionRegistryConfigError::InvalidSessionTtl);
130        }
131        if max_tombstones == 0 || max_tombstones > Self::HARD_MAX_TOMBSTONES {
132            return Err(SessionRegistryConfigError::InvalidMaxTombstones);
133        }
134        if max_tombstone_bytes == 0 || max_tombstone_bytes > Self::HARD_MAX_TOMBSTONE_BYTES {
135            return Err(SessionRegistryConfigError::InvalidMaxTombstoneBytes);
136        }
137        if tombstone_ttl.is_zero() || tombstone_ttl > Self::HARD_MAX_TOMBSTONE_TTL {
138            return Err(SessionRegistryConfigError::InvalidTombstoneTtl);
139        }
140        Ok(Self {
141            max_active_sessions,
142            session_ttl,
143            max_tombstones,
144            max_tombstone_bytes,
145            tombstone_ttl,
146        })
147    }
148
149    #[must_use]
150    pub const fn max_active_sessions(&self) -> usize {
151        self.max_active_sessions
152    }
153
154    #[must_use]
155    pub const fn session_ttl(&self) -> Duration {
156        self.session_ttl
157    }
158
159    #[must_use]
160    pub const fn max_tombstones(&self) -> usize {
161        self.max_tombstones
162    }
163
164    #[must_use]
165    pub const fn max_tombstone_bytes(&self) -> usize {
166        self.max_tombstone_bytes
167    }
168
169    #[must_use]
170    pub const fn tombstone_ttl(&self) -> Duration {
171        self.tombstone_ttl
172    }
173
174    pub fn try_with_max_active_sessions(
175        mut self,
176        value: usize,
177    ) -> Result<Self, SessionRegistryConfigError> {
178        if value == 0 || value > Self::HARD_MAX_ACTIVE_SESSIONS {
179            return Err(SessionRegistryConfigError::InvalidMaxActiveSessions);
180        }
181        if value > self.max_active_sessions {
182            return Err(SessionRegistryConfigError::NotALowering);
183        }
184        self.max_active_sessions = value;
185        Ok(self)
186    }
187
188    pub fn try_with_session_ttl(
189        mut self,
190        value: Duration,
191    ) -> Result<Self, SessionRegistryConfigError> {
192        if value.is_zero() || value > Self::HARD_MAX_SESSION_TTL {
193            return Err(SessionRegistryConfigError::InvalidSessionTtl);
194        }
195        if value > self.session_ttl {
196            return Err(SessionRegistryConfigError::NotALowering);
197        }
198        self.session_ttl = value;
199        Ok(self)
200    }
201
202    pub fn try_with_max_tombstones(
203        mut self,
204        value: usize,
205    ) -> Result<Self, SessionRegistryConfigError> {
206        if value == 0 || value > Self::HARD_MAX_TOMBSTONES {
207            return Err(SessionRegistryConfigError::InvalidMaxTombstones);
208        }
209        if value > self.max_tombstones {
210            return Err(SessionRegistryConfigError::NotALowering);
211        }
212        self.max_tombstones = value;
213        Ok(self)
214    }
215
216    pub fn try_with_max_tombstone_bytes(
217        mut self,
218        value: usize,
219    ) -> Result<Self, SessionRegistryConfigError> {
220        if value == 0 || value > Self::HARD_MAX_TOMBSTONE_BYTES {
221            return Err(SessionRegistryConfigError::InvalidMaxTombstoneBytes);
222        }
223        if value > self.max_tombstone_bytes {
224            return Err(SessionRegistryConfigError::NotALowering);
225        }
226        self.max_tombstone_bytes = value;
227        Ok(self)
228    }
229
230    pub fn try_with_tombstone_ttl(
231        mut self,
232        value: Duration,
233    ) -> Result<Self, SessionRegistryConfigError> {
234        if value.is_zero() || value > Self::HARD_MAX_TOMBSTONE_TTL {
235            return Err(SessionRegistryConfigError::InvalidTombstoneTtl);
236        }
237        if value > self.tombstone_ttl {
238            return Err(SessionRegistryConfigError::NotALowering);
239        }
240        self.tombstone_ttl = value;
241        Ok(self)
242    }
243}
244
245/// Route matching policy declared by an adapter.
246#[derive(Clone, Copy, Debug, Eq, PartialEq)]
247pub enum RoutePolicy {
248    LegacyAdapterMatch,
249    ExactMethodAndPath,
250}
251
252/// Forwarded-header policy declared by an adapter.
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub enum HeaderPolicy {
255    LegacyForwarded,
256    StrictAllowlist,
257}
258
259/// Redirect policy declared by an adapter.
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub enum RedirectPolicy {
262    LegacyClient,
263    Deny,
264}
265
266/// PII coverage proof policy declared by an adapter.
267#[derive(Clone, Copy, Debug, Eq, PartialEq)]
268pub enum CoveragePolicy {
269    LegacySurfaces,
270    CodecProved,
271}
272
273/// Request, response, and streaming wire formats accepted by an adapter.
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub struct FormatPolicy {
276    request: WireFormat,
277    response: WireFormat,
278    streaming_response: WireFormat,
279}
280
281impl FormatPolicy {
282    /// Characterizes the pre-codec JSON request/response and SSE stream behavior.
283    #[must_use]
284    pub const fn legacy() -> Self {
285        Self {
286            request: WireFormat::Json,
287            response: WireFormat::Json,
288            streaming_response: WireFormat::Sse,
289        }
290    }
291
292    #[must_use]
293    pub const fn request(&self) -> WireFormat {
294        self.request
295    }
296
297    #[must_use]
298    pub const fn response(&self) -> WireFormat {
299        self.response
300    }
301
302    #[must_use]
303    pub const fn streaming_response(&self) -> WireFormat {
304        self.streaming_response
305    }
306}
307
308/// Reviewed protocol, session, routing, and coverage declarations for an adapter.
309///
310/// Fields are private so downstream code cannot assemble an unreviewed contract.
311/// Existing adapters obtain [`Self::legacy`] through the default trait method.
312///
313/// ```compile_fail
314/// use gaze_proxy::{AdapterContract, ProtocolContract};
315///
316/// let _ = AdapterContract { protocol: ProtocolContract::LegacySurfaces };
317/// ```
318#[derive(Clone, Copy, Debug)]
319pub struct AdapterContract<'a> {
320    protocol: ProtocolContract<'a>,
321    session_policy: Option<SessionPolicy>,
322    route_policy: RoutePolicy,
323    header_policy: HeaderPolicy,
324    redirect_policy: RedirectPolicy,
325    coverage_policy: CoveragePolicy,
326    formats: FormatPolicy,
327    codec_limits: Option<CodecLimits>,
328}
329
330impl AdapterContract<'static> {
331    /// Characterizes the existing adapter behavior without changing it.
332    #[must_use]
333    pub const fn legacy() -> Self {
334        Self {
335            protocol: ProtocolContract::LegacySurfaces,
336            session_policy: None,
337            route_policy: RoutePolicy::LegacyAdapterMatch,
338            header_policy: HeaderPolicy::LegacyForwarded,
339            redirect_policy: RedirectPolicy::LegacyClient,
340            coverage_policy: CoveragePolicy::LegacySurfaces,
341            formats: FormatPolicy::legacy(),
342            codec_limits: None,
343        }
344    }
345}
346
347impl<'a> AdapterContract<'a> {
348    /// Constructs the only non-legacy policy bundle accepted by later direct adapters.
349    #[allow(
350        dead_code,
351        reason = "consumed by the direct adapter in the next serialized checkpoint"
352    )]
353    pub(crate) const fn codec(
354        codec: &'a dyn BodyCodec,
355        session_policy: SessionPolicy,
356        codec_limits: CodecLimits,
357    ) -> Self {
358        Self {
359            protocol: ProtocolContract::Codec(codec),
360            session_policy: Some(session_policy),
361            route_policy: RoutePolicy::ExactMethodAndPath,
362            header_policy: HeaderPolicy::StrictAllowlist,
363            redirect_policy: RedirectPolicy::Deny,
364            coverage_policy: CoveragePolicy::CodecProved,
365            formats: FormatPolicy::legacy(),
366            codec_limits: Some(codec_limits),
367        }
368    }
369
370    #[must_use]
371    pub const fn protocol(&self) -> ProtocolContract<'a> {
372        self.protocol
373    }
374
375    /// Legacy adapters preserve the server's pre-contract auto-create behavior.
376    #[must_use]
377    pub const fn uses_legacy_session_auto_create(&self) -> bool {
378        self.session_policy.is_none()
379    }
380
381    #[must_use]
382    pub const fn session_policy(&self) -> Option<SessionPolicy> {
383        self.session_policy
384    }
385
386    #[must_use]
387    pub const fn route_policy(&self) -> RoutePolicy {
388        self.route_policy
389    }
390
391    #[must_use]
392    pub const fn header_policy(&self) -> HeaderPolicy {
393        self.header_policy
394    }
395
396    #[must_use]
397    pub const fn redirect_policy(&self) -> RedirectPolicy {
398        self.redirect_policy
399    }
400
401    #[must_use]
402    pub const fn coverage_policy(&self) -> CoveragePolicy {
403        self.coverage_policy
404    }
405
406    #[must_use]
407    pub const fn formats(&self) -> FormatPolicy {
408        self.formats
409    }
410
411    #[must_use]
412    pub const fn codec_limits(&self) -> Option<CodecLimits> {
413        self.codec_limits
414    }
415}
416
417#[async_trait::async_trait]
418pub trait ProviderAdapter: Send + Sync + 'static {
419    /// Declares protocol and safety behavior while preserving old implementations.
420    fn contract(&self) -> AdapterContract<'_> {
421        AdapterContract::legacy()
422    }
423
424    fn name(&self) -> &'static str;
425    fn matches_path(&self, method: &Method, path: &str) -> bool;
426    fn upstream_base(&self) -> &Url;
427    fn request_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>>;
428    fn response_pii_surfaces<'a>(&self, body: &'a mut Value) -> Vec<PiiSurface<'a>>;
429    fn sse_event_pii_surfaces<'a>(&self, event: &'a mut SseEvent) -> Vec<PiiSurface<'a>>;
430}
431
432pub struct PiiSurface<'a> {
433    pub field_path: String,
434    pub text: &'a mut String,
435}
436
437#[derive(Debug, Clone)]
438#[non_exhaustive]
439pub struct SseEvent {
440    pub event: Option<String>,
441    pub data: Value,
442}
443
444impl SseEvent {
445    pub fn new(event: Option<String>, data: Value) -> Self {
446        Self { event, data }
447    }
448}
449
450pub(crate) fn push_string<'a>(
451    surfaces: &mut Vec<PiiSurface<'a>>,
452    field_path: impl Into<String>,
453    value: &'a mut Value,
454) {
455    if let Value::String(text) = value {
456        surfaces.push(PiiSurface {
457            field_path: field_path.into(),
458            text,
459        });
460    }
461}
462
463pub(crate) fn walk_all_strings<'a>(
464    surfaces: &mut Vec<PiiSurface<'a>>,
465    prefix: String,
466    value: &'a mut Value,
467) {
468    match value {
469        Value::String(text) => surfaces.push(PiiSurface {
470            field_path: prefix,
471            text,
472        }),
473        Value::Array(items) => {
474            for (index, item) in items.iter_mut().enumerate() {
475                walk_all_strings(surfaces, format!("{prefix}[{index}]"), item);
476            }
477        }
478        Value::Object(map) => {
479            for (key, child) in map {
480                let path = if prefix.is_empty() {
481                    key.clone()
482                } else {
483                    format!("{prefix}.{key}")
484                };
485                walk_all_strings(surfaces, path, child);
486            }
487        }
488        Value::Null | Value::Bool(_) | Value::Number(_) => {}
489    }
490}
491
492pub(crate) fn push_text_blocks<'a>(
493    surfaces: &mut Vec<PiiSurface<'a>>,
494    prefix: &str,
495    value: &'a mut Value,
496) {
497    match value {
498        Value::String(text) => surfaces.push(PiiSurface {
499            field_path: prefix.to_string(),
500            text,
501        }),
502        Value::Array(items) => {
503            for (index, item) in items.iter_mut().enumerate() {
504                match item {
505                    Value::String(text) => surfaces.push(PiiSurface {
506                        field_path: format!("{prefix}[{index}]"),
507                        text,
508                    }),
509                    Value::Object(map) => {
510                        if matches!(map.get("type"), Some(Value::String(kind)) if kind == "text") {
511                            if let Some(Value::String(text)) = map.get_mut("text") {
512                                surfaces.push(PiiSurface {
513                                    field_path: format!("{prefix}[{index}].text"),
514                                    text,
515                                });
516                            }
517                        }
518                    }
519                    _ => {}
520                }
521            }
522        }
523        _ => {}
524    }
525}