Skip to main content

gaze_proxy/
error.rs

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