Skip to main content

gaze_proxy/
error.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use http::StatusCode;
5
6use http::Method;
7use thiserror::Error;
8use url::Url;
9
10use crate::codec::OpaqueCarrierLocation;
11
12#[derive(Debug, Error)]
13#[non_exhaustive]
14pub enum ProxyError {
15    #[error("upstream unreachable: {url}")]
16    UpstreamUnreachable {
17        url: Url,
18        #[source]
19        source: reqwest::Error,
20    },
21    #[error("adapter not found for {method} {path}")]
22    AdapterNotFound { path: String, method: Method },
23    #[error("body too large: limit {limit_bytes} bytes")]
24    BodyTooLarge { limit_bytes: u64 },
25    #[error("invalid json body")]
26    InvalidJson {
27        #[source]
28        source: serde_json::Error,
29    },
30    #[error("sse partial frame: {reason}")]
31    SsePartialFrame { reason: String },
32    /// A legacy-path request carried PII in a position no adapter surface covered.
33    ///
34    /// Raised by the outbound request residual re-scan, which fails closed rather than
35    /// silently rewriting provider-owned fields. The variant carries the JSON field path
36    /// ONLY -- never the detected value, nor any substring of it -- so neither `Display`,
37    /// `Debug`, nor any log line derived from them can egress protected bytes.
38    #[error("unsurfaced pii at {field_path}")]
39    UnsurfacedPii { field_path: String },
40    /// A request reached the legacy path under a contract that claims codec-proved coverage.
41    ///
42    /// The contract and the router disagree, so no coverage mechanism is known to have run.
43    /// Proxying under an unproven coverage claim is exactly the failure this policy exists to
44    /// prevent, so the request is refused.
45    #[error("adapter coverage contract is unproven on the legacy path")]
46    UnprovenCoverage,
47    /// The request carried text the configured protection refused to forward.
48    ///
49    /// Carries the typed [`gaze::ProtectionError`] and suspect classes only, never the text.
50    #[error("request refused: {refusal}")]
51    Refused { refusal: gaze::BoundaryRefusal },
52    #[error("pipeline failed")]
53    Pipeline {
54        #[source]
55        source: gaze::Error,
56    },
57    #[error("http server failed")]
58    Server {
59        #[source]
60        source: std::io::Error,
61    },
62    #[error("daemon already running: pid {pid} ({pidfile})", pidfile = pidfile.display())]
63    DaemonAlreadyRunning { pid: u32, pidfile: PathBuf },
64    #[error("daemon is not running")]
65    DaemonNotRunning,
66    #[error("daemon pidfile stale: {pidfile}", pidfile = pidfile.display())]
67    DaemonPidfileStale { pidfile: PathBuf },
68    #[error("daemon io failed: {path}", path = path.display())]
69    DaemonIo {
70        path: PathBuf,
71        #[source]
72        source: std::io::Error,
73    },
74    #[error("daemon config failed: {detail}")]
75    DaemonConfig { detail: String },
76    /// The detached daemon child exited before it could serve.
77    ///
78    /// The child resolves the configured policy, so its early exit is normally a
79    /// fail-closed policy load. Reporting that as a successful start is what let
80    /// an unloadable policy look like a running chokepoint.
81    #[error(
82        "daemon exited before serving ({code}); see {stderr_file}",
83        code = code.map_or_else(|| "terminated by signal".to_string(), |code| format!("exit code {code}")),
84        stderr_file = stderr_file.display()
85    )]
86    DaemonExitedEarly {
87        code: Option<i32>,
88        stderr_file: PathBuf,
89    },
90}
91
92/// Closed failure codes used by the direct proxy profile.
93///
94/// Unlike [`ProxyError`], this surface never stores provider, request, URL, parser, transport, or
95/// operating-system error text.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum ProxyErrorCode {
98    RouteRejected,
99    HeaderRejected,
100    InvalidUpstreamHeader,
101    SessionIdentityRequired,
102    UnexpectedSessionHeader,
103    SessionExpired,
104    SessionGenerationConflict,
105    SessionCommitFailure,
106    PrincipalRequired,
107    PrincipalRejected,
108    RegistryCapacity,
109    InvalidUpstreamUrl,
110    ProxyConfiguration,
111    UnsupportedContentEncoding,
112    InvalidFraming,
113    RequestBodyLimitExceeded,
114    ResponseBodyLimitExceeded,
115    HeaderLimitExceeded,
116    InvalidRequestFormat,
117    InvalidUpstreamResponseFormat,
118    DuplicateObjectKey,
119    InternalCoverageFailure,
120    ControlWouldMutate,
121    OpaqueMediaUninspected,
122    InvalidProvenance,
123    InvalidToken,
124    SignedMutationRequired,
125    SignedSurfaceMalformed,
126    InvalidSseLifecycle,
127    InvalidContentBlockIndex,
128    ConnectTimeout,
129    RequestTimeout,
130    TotalTimeout,
131    UpstreamRedirect,
132    UpstreamBadRequest,
133    UpstreamUnauthorized,
134    UpstreamForbidden,
135    UpstreamNotFound,
136    UpstreamConflict,
137    UpstreamPayloadTooLarge,
138    UpstreamRateLimited,
139    UpstreamClientFailure,
140    UpstreamUnavailable,
141    UpstreamServerFailure,
142    UpstreamUnreachable,
143    UpstreamProtocol,
144    InspectionInternal,
145    InvalidStateTransition,
146    /// Request protection refused a surface; the error carries the typed refusal.
147    ProtectionRefused,
148}
149
150impl ProxyErrorCode {
151    /// Constructs a sanitized direct-profile error at a closed phase.
152    #[must_use]
153    pub const fn error(self, phase: ProxyErrorPhase) -> DirectProxyError {
154        DirectProxyError::new(self, phase)
155    }
156
157    /// Returns the single reviewed downstream status for this code.
158    #[must_use]
159    pub const fn http_status(self) -> StatusCode {
160        match self {
161            Self::RouteRejected => StatusCode::NOT_FOUND,
162            Self::HeaderRejected
163            | Self::SessionIdentityRequired
164            | Self::UnexpectedSessionHeader
165            | Self::InvalidRequestFormat
166            | Self::DuplicateObjectKey => StatusCode::BAD_REQUEST,
167            Self::PrincipalRequired | Self::UpstreamUnauthorized => StatusCode::UNAUTHORIZED,
168            Self::PrincipalRejected | Self::UpstreamForbidden => StatusCode::FORBIDDEN,
169            Self::SessionExpired => StatusCode::GONE,
170            Self::SessionGenerationConflict | Self::UpstreamConflict => StatusCode::CONFLICT,
171            Self::RegistryCapacity
172            | Self::InvalidUpstreamUrl
173            | Self::ProxyConfiguration
174            | Self::UpstreamUnavailable => StatusCode::SERVICE_UNAVAILABLE,
175            Self::RequestBodyLimitExceeded | Self::UpstreamPayloadTooLarge => {
176                StatusCode::PAYLOAD_TOO_LARGE
177            }
178            Self::ControlWouldMutate | Self::OpaqueMediaUninspected | Self::ProtectionRefused => {
179                StatusCode::UNPROCESSABLE_ENTITY
180            }
181            Self::ConnectTimeout | Self::RequestTimeout | Self::TotalTimeout => {
182                StatusCode::GATEWAY_TIMEOUT
183            }
184            Self::UpstreamBadRequest => StatusCode::BAD_REQUEST,
185            Self::UpstreamNotFound => StatusCode::NOT_FOUND,
186            Self::UpstreamRateLimited => StatusCode::TOO_MANY_REQUESTS,
187            Self::SessionCommitFailure
188            | Self::InspectionInternal
189            | Self::InvalidStateTransition => StatusCode::INTERNAL_SERVER_ERROR,
190            Self::InvalidUpstreamHeader
191            | Self::UnsupportedContentEncoding
192            | Self::InvalidFraming
193            | Self::ResponseBodyLimitExceeded
194            | Self::HeaderLimitExceeded
195            | Self::InvalidUpstreamResponseFormat
196            | Self::InternalCoverageFailure
197            | Self::InvalidProvenance
198            | Self::InvalidToken
199            | Self::SignedMutationRequired
200            | Self::SignedSurfaceMalformed
201            | Self::InvalidSseLifecycle
202            | Self::InvalidContentBlockIndex
203            | Self::UpstreamRedirect
204            | Self::UpstreamClientFailure
205            | Self::UpstreamServerFailure
206            | Self::UpstreamUnreachable
207            | Self::UpstreamProtocol => StatusCode::BAD_GATEWAY,
208        }
209    }
210}
211
212/// Closed processing phase carried by a direct-profile failure.
213#[derive(Clone, Copy, Debug, Eq, PartialEq)]
214pub enum ProxyErrorPhase {
215    Ingress,
216    RequestValidation,
217    Session,
218    RequestTransform,
219    UpstreamConfiguration,
220    UpstreamConnect,
221    UpstreamHeaders,
222    UpstreamBody,
223    ResponseValidation,
224    ResponseTransform,
225    ResponseReplay,
226    Inspection,
227    Framework,
228}
229
230/// Structural reason a request header was rejected.
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232#[non_exhaustive]
233pub enum HeaderRejectionReason {
234    NameNotAllowlisted,
235    EmptyValue,
236    DuplicateSingleton,
237}
238
239impl HeaderRejectionReason {
240    /// Returns the stable downstream identifier for this rejection cause.
241    #[must_use]
242    pub const fn as_str(self) -> &'static str {
243        match self {
244            Self::NameNotAllowlisted => "name_not_allowlisted",
245            Self::EmptyValue => "empty_value",
246            Self::DuplicateSingleton => "duplicate_singleton",
247        }
248    }
249}
250
251const MAX_STRUCTURAL_HEADER_NAME_BYTES: usize = 128;
252const PACKED_STRUCTURAL_HEADER_NAME_BYTES: usize = MAX_STRUCTURAL_HEADER_NAME_BYTES * 6 / 8;
253
254// `HeaderName::as_str()` uses the 51 lowercase HTTP token characters. Six-bit packing retains
255// every reviewed 128-byte header name exactly while keeping the closed error small and `Copy`.
256#[derive(Clone, Copy, Eq, PartialEq)]
257struct HeaderRejectionIdentifier {
258    packed_name: [u8; PACKED_STRUCTURAL_HEADER_NAME_BYTES],
259    name_length: u8,
260    reason: HeaderRejectionReason,
261}
262
263impl HeaderRejectionIdentifier {
264    fn new(name: &str, reason: HeaderRejectionReason) -> Option<Self> {
265        let name_length = u8::try_from(name.len()).ok()?;
266        if name.len() > MAX_STRUCTURAL_HEADER_NAME_BYTES {
267            return None;
268        }
269        let mut packed_name = [0; PACKED_STRUCTURAL_HEADER_NAME_BYTES];
270        for (index, byte) in name.bytes().enumerate() {
271            let code = encode_header_name_byte(byte)?;
272            let bit_offset = index * 6;
273            let byte_offset = bit_offset / 8;
274            let shift = bit_offset % 8;
275            packed_name[byte_offset] |= code << shift;
276            if shift > 2 {
277                packed_name[byte_offset + 1] |= code >> (8 - shift);
278            }
279        }
280        Some(Self {
281            packed_name,
282            name_length,
283            reason,
284        })
285    }
286
287    fn name(self) -> String {
288        let mut name = String::with_capacity(usize::from(self.name_length));
289        for index in 0..usize::from(self.name_length) {
290            let bit_offset = index * 6;
291            let byte_offset = bit_offset / 8;
292            let shift = bit_offset % 8;
293            let mut code = self.packed_name[byte_offset] >> shift;
294            if shift > 2 {
295                code |= self.packed_name[byte_offset + 1] << (8 - shift);
296            }
297            name.push(char::from(
298                decode_header_name_byte(code & 0x3f)
299                    .expect("packed header names contain only validated token bytes"),
300            ));
301        }
302        name
303    }
304}
305
306fn encode_header_name_byte(byte: u8) -> Option<u8> {
307    match byte {
308        b'a'..=b'z' => Some(byte - b'a'),
309        b'0'..=b'9' => Some(26 + byte - b'0'),
310        b'!' => Some(36),
311        b'#' => Some(37),
312        b'$' => Some(38),
313        b'%' => Some(39),
314        b'&' => Some(40),
315        b'\'' => Some(41),
316        b'*' => Some(42),
317        b'+' => Some(43),
318        b'-' => Some(44),
319        b'.' => Some(45),
320        b'^' => Some(46),
321        b'_' => Some(47),
322        b'`' => Some(48),
323        b'|' => Some(49),
324        b'~' => Some(50),
325        _ => None,
326    }
327}
328
329fn decode_header_name_byte(code: u8) -> Option<u8> {
330    match code {
331        0..=25 => Some(b'a' + code),
332        26..=35 => Some(b'0' + code - 26),
333        36 => Some(b'!'),
334        37 => Some(b'#'),
335        38 => Some(b'$'),
336        39 => Some(b'%'),
337        40 => Some(b'&'),
338        41 => Some(b'\''),
339        42 => Some(b'*'),
340        43 => Some(b'+'),
341        44 => Some(b'-'),
342        45 => Some(b'.'),
343        46 => Some(b'^'),
344        47 => Some(b'_'),
345        48 => Some(b'`'),
346        49 => Some(b'|'),
347        50 => Some(b'~'),
348        _ => None,
349    }
350}
351
352impl fmt::Debug for HeaderRejectionIdentifier {
353    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
354        formatter
355            .debug_struct("Header")
356            .field("name", &self.name())
357            .field("reason", &self.reason)
358            .finish()
359    }
360}
361
362#[derive(Clone, Debug, Eq, PartialEq)]
363enum DirectProxyErrorIdentifier {
364    Header(HeaderRejectionIdentifier),
365    OpaqueCarrier(OpaqueCarrierLocation),
366    Refusal(gaze::BoundaryRefusal),
367}
368
369/// Sanitized direct-profile failure.
370///
371/// `Display`, `Debug`, and the source chain expose only its closed code, phase, and reviewed
372/// structural identifiers. Header values and carrier bytes are never retained.
373///
374/// This follows the field-path-only precedent established for [`ProxyError::UnsurfacedPii`] in
375/// #400: report the structural location that makes a rejection actionable, never the value.
376#[derive(Clone, Eq, PartialEq)]
377pub struct DirectProxyError {
378    code: ProxyErrorCode,
379    phase: ProxyErrorPhase,
380    retry_after_seconds: Option<u32>,
381    identifier: Option<DirectProxyErrorIdentifier>,
382}
383
384impl DirectProxyError {
385    #[must_use]
386    pub const fn new(code: ProxyErrorCode, phase: ProxyErrorPhase) -> Self {
387        Self {
388            code,
389            phase,
390            retry_after_seconds: None,
391            identifier: None,
392        }
393    }
394
395    #[must_use]
396    pub(crate) const fn with_retry_after_seconds(mut self, seconds: Option<u32>) -> Self {
397        self.retry_after_seconds = seconds;
398        self
399    }
400
401    pub(crate) fn with_header_rejection(
402        mut self,
403        name: &str,
404        reason: HeaderRejectionReason,
405    ) -> Self {
406        self.identifier =
407            HeaderRejectionIdentifier::new(name, reason).map(DirectProxyErrorIdentifier::Header);
408        self
409    }
410
411    pub(crate) fn with_opaque_carrier(mut self, opaque_carrier: OpaqueCarrierLocation) -> Self {
412        self.identifier = Some(DirectProxyErrorIdentifier::OpaqueCarrier(opaque_carrier));
413        self
414    }
415
416    pub(crate) fn with_refusal(mut self, refusal: gaze::BoundaryRefusal) -> Self {
417        self.identifier = Some(DirectProxyErrorIdentifier::Refusal(refusal));
418        self
419    }
420
421    /// Returns the typed protection refusal: closed variants and class names, never text.
422    #[must_use]
423    pub fn refusal(&self) -> Option<&gaze::BoundaryRefusal> {
424        match &self.identifier {
425            Some(DirectProxyErrorIdentifier::Refusal(refusal)) => Some(refusal),
426            _ => None,
427        }
428    }
429
430    #[must_use]
431    pub const fn code(&self) -> ProxyErrorCode {
432        self.code
433    }
434
435    #[must_use]
436    pub const fn phase(&self) -> ProxyErrorPhase {
437        self.phase
438    }
439
440    #[must_use]
441    pub const fn http_status(&self) -> StatusCode {
442        self.code.http_status()
443    }
444
445    #[must_use]
446    pub const fn retry_after_seconds(&self) -> Option<u32> {
447        self.retry_after_seconds
448    }
449
450    /// Returns the rejected header name without retaining or exposing its value.
451    #[must_use]
452    pub fn header_name(&self) -> Option<String> {
453        match &self.identifier {
454            Some(DirectProxyErrorIdentifier::Header(identifier)) => Some(identifier.name()),
455            _ => None,
456        }
457    }
458
459    /// Returns the structural header-rejection cause.
460    #[must_use]
461    pub const fn header_rejection_reason(&self) -> Option<HeaderRejectionReason> {
462        match &self.identifier {
463            Some(DirectProxyErrorIdentifier::Header(identifier)) => Some(identifier.reason),
464            _ => None,
465        }
466    }
467
468    /// Returns only the opaque carrier's scheme and structural location.
469    #[must_use]
470    pub const fn opaque_carrier(&self) -> Option<OpaqueCarrierLocation> {
471        match &self.identifier {
472            Some(DirectProxyErrorIdentifier::OpaqueCarrier(location)) => Some(*location),
473            _ => None,
474        }
475    }
476
477    #[must_use]
478    pub const fn sse_error_frame(&self) -> &'static [u8] {
479        crate::server::DIRECT_PROXY_ERROR_FRAME
480    }
481}
482
483impl fmt::Debug for DirectProxyError {
484    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
485        let mut debug = formatter.debug_struct("DirectProxyError");
486        debug.field("code", &self.code).field("phase", &self.phase);
487        if let Some(identifier) = &self.identifier {
488            debug.field("identifier", identifier);
489        }
490        debug.finish()
491    }
492}
493
494impl fmt::Display for DirectProxyError {
495    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
496        write!(formatter, "proxy_{:?}_{:?}", self.phase, self.code)
497    }
498}
499
500impl std::error::Error for DirectProxyError {}