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