structured_proxy/transcode/error.rs
1//! gRPC → HTTP error mapping.
2//!
3//! Converts `tonic::Status` to appropriate HTTP status codes and JSON error bodies
4//! following the gRPC-HTTP status code mapping from the gRPC specification, and
5//! renders the typed `google.rpc.Status` details the upstream attached in the
6//! `grpc-status-details-bin` trailer.
7
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use axum::Json;
11use base64::Engine as _;
12use globset::GlobMatcher;
13use prost::Message as _;
14use prost_reflect::{
15 Cardinality, DescriptorPool, DynamicMessage, Kind, MessageDescriptor, ReflectMessage,
16 SerializeOptions, Value as PbValue,
17};
18use serde_json::{Map, Value};
19
20/// Full name of `google.rpc.DebugInfo`. It carries stack traces and server
21/// internals meant for the service's operators, so it never reaches an HTTP
22/// client.
23const DEBUG_INFO: &str = "google.rpc.DebugInfo";
24
25/// Map a gRPC status code to the corresponding HTTP status code.
26///
27/// Based on <https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md>
28pub fn grpc_to_http_status(code: tonic::Code) -> StatusCode {
29 match code {
30 tonic::Code::Ok => StatusCode::OK,
31 tonic::Code::Cancelled => StatusCode::from_u16(499).unwrap_or(StatusCode::BAD_REQUEST),
32 tonic::Code::Unknown => StatusCode::INTERNAL_SERVER_ERROR,
33 tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST,
34 tonic::Code::DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
35 tonic::Code::NotFound => StatusCode::NOT_FOUND,
36 tonic::Code::AlreadyExists => StatusCode::CONFLICT,
37 tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
38 tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
39 tonic::Code::FailedPrecondition => StatusCode::BAD_REQUEST,
40 tonic::Code::Aborted => StatusCode::CONFLICT,
41 tonic::Code::OutOfRange => StatusCode::BAD_REQUEST,
42 tonic::Code::Unimplemented => StatusCode::NOT_IMPLEMENTED,
43 tonic::Code::Internal => StatusCode::INTERNAL_SERVER_ERROR,
44 tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
45 tonic::Code::DataLoss => StatusCode::INTERNAL_SERVER_ERROR,
46 tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED,
47 }
48}
49
50/// Message of the `INTERNAL` a client gets instead of an upstream error whose
51/// details cannot be rendered faithfully. The cause stays in the proxy's log.
52const MALFORMED_STATUS_MESSAGE: &str = "upstream returned a malformed error status";
53
54/// An upstream error status whose details cannot be rendered faithfully: a
55/// trailer that is not a `google.rpc.Status`, or a detail of a known type whose
56/// bytes do not decode or whose value has no valid JSON form. Its cause is
57/// logged where it is detected.
58#[derive(Debug)]
59struct MalformedStatus;
60
61/// Convert a `tonic::Status` into an axum HTTP response with a JSON error body
62/// `{"error", "message", "code"}`, without status details.
63///
64/// Use [`status_to_response_with_details`] to also render the upstream's
65/// `google.rpc.Status` details.
66pub fn status_to_response(status: tonic::Status) -> Response {
67 status_to_response_with_details(&status, None)
68}
69
70/// Convert a `tonic::Status` into an axum HTTP response with a JSON error body.
71///
72/// The body is `{"error", "message", "code"}`, plus a `details` array when
73/// `details` is given (see [`error_body`]). The HTTP status follows the code the
74/// body reports, so a malformed upstream status answers 500.
75pub fn status_to_response_with_details(
76 status: &tonic::Status,
77 details: Option<&StatusDetails>,
78) -> Response {
79 render_response(status, details).0
80}
81
82/// [`status_to_response_with_details`], plus whether the response reports the
83/// upstream's own error: `false` when its details could not be rendered
84/// faithfully and the response is the generic `INTERNAL` instead, which then
85/// carries nothing else from the upstream either.
86pub(crate) fn render_response(
87 status: &tonic::Status,
88 details: Option<&StatusDetails>,
89) -> (Response, bool) {
90 let (code, body, faithful) = render(status, details);
91 (
92 (grpc_to_http_status(code), Json(body)).into_response(),
93 faithful,
94 )
95}
96
97/// Message of the `INTERNAL` a client gets instead of a successful upstream
98/// answer the proxy cannot turn into a faithful HTTP response (an invalid
99/// `x-http-code`, an `HttpBody` content type that is not a header value).
100const MALFORMED_RESPONSE_MESSAGE: &str = "upstream returned a malformed response";
101
102/// The `INTERNAL` (500) answering a successful upstream call whose response
103/// cannot be passed on faithfully, in the route's error body. Like a malformed
104/// error status, nothing of the upstream's answer reaches the client; the
105/// caller logs the cause.
106pub(crate) fn malformed_response(details: Option<&StatusDetails>) -> Response {
107 let body = body(
108 tonic::Code::Internal,
109 MALFORMED_RESPONSE_MESSAGE,
110 details.map(|_| RenderedDetails::default()),
111 );
112 (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
113}
114
115/// The JSON error body for a failed call, shared by the unary response and the
116/// terminal frame of a stream so a client parses one shape everywhere.
117///
118/// `error` is the gRPC code name, `code` its number and `message` the status
119/// message. With `details`, the body also carries `details`: the upstream's
120/// `google.rpc.Status.details` in proto3 JSON form (empty when the upstream sent
121/// none), plus `opaqueDetails` for details whose type no descriptor describes
122/// when the renderer forwards them ([`StatusDetails::with_opaque_details`]);
123/// without it, both keys are absent and the trailer is not read. When the
124/// details cannot be rendered faithfully the whole body is a generic `INTERNAL`
125/// instead, never a partial or reinterpreted set of details.
126pub fn error_body(status: &tonic::Status, details: Option<&StatusDetails>) -> Value {
127 render(status, details).1
128}
129
130/// The error body, the gRPC code it reports and whether that is the
131/// upstream's own error: `false` when its details cannot be rendered
132/// faithfully and the body is the generic `INTERNAL`.
133fn render(status: &tonic::Status, details: Option<&StatusDetails>) -> (tonic::Code, Value, bool) {
134 let Some(details) = details else {
135 return (
136 status.code(),
137 body(status.code(), status.message(), None),
138 true,
139 );
140 };
141 match details.render(status) {
142 Ok(rendered) => (
143 status.code(),
144 body(status.code(), status.message(), Some(rendered)),
145 true,
146 ),
147 Err(MalformedStatus) => (
148 tonic::Code::Internal,
149 body(
150 tonic::Code::Internal,
151 MALFORMED_STATUS_MESSAGE,
152 Some(RenderedDetails::default()),
153 ),
154 false,
155 ),
156 }
157}
158
159/// `opaqueDetails` appears only when a detail went to the extension, so a
160/// client that does not handle it sees nothing new otherwise.
161fn body(code: tonic::Code, message: &str, details: Option<RenderedDetails>) -> Value {
162 let mut body = Map::with_capacity(5);
163 body.insert("error".into(), grpc_code_name(code).into());
164 body.insert("message".into(), message.into());
165 body.insert("code".into(), (code as i32).into());
166 if let Some(rendered) = details {
167 body.insert("details".into(), Value::Array(rendered.details));
168 if !rendered.opaque.is_empty() {
169 body.insert("opaqueDetails".into(), Value::Array(rendered.opaque));
170 }
171 }
172 Value::Object(body)
173}
174
175/// Human-readable gRPC code name for JSON error responses.
176pub(crate) fn grpc_code_name(code: tonic::Code) -> &'static str {
177 match code {
178 tonic::Code::Ok => "OK",
179 tonic::Code::Cancelled => "CANCELLED",
180 tonic::Code::Unknown => "UNKNOWN",
181 tonic::Code::InvalidArgument => "INVALID_ARGUMENT",
182 tonic::Code::DeadlineExceeded => "DEADLINE_EXCEEDED",
183 tonic::Code::NotFound => "NOT_FOUND",
184 tonic::Code::AlreadyExists => "ALREADY_EXISTS",
185 tonic::Code::PermissionDenied => "PERMISSION_DENIED",
186 tonic::Code::ResourceExhausted => "RESOURCE_EXHAUSTED",
187 tonic::Code::FailedPrecondition => "FAILED_PRECONDITION",
188 tonic::Code::Aborted => "ABORTED",
189 tonic::Code::OutOfRange => "OUT_OF_RANGE",
190 tonic::Code::Unimplemented => "UNIMPLEMENTED",
191 tonic::Code::Internal => "INTERNAL",
192 tonic::Code::Unavailable => "UNAVAILABLE",
193 tonic::Code::DataLoss => "DATA_LOSS",
194 tonic::Code::Unauthenticated => "UNAUTHENTICATED",
195 }
196}
197
198/// Which routes return `details` in their error bodies.
199///
200/// A global switch plus per-route overrides, checked in the order they were
201/// added: the first whose pattern matches the route decides. A pattern is a
202/// glob over the mounted route path, where `*` stays within one segment and
203/// `**` spans segments; every path parameter counts as one segment, so
204/// `/v1/users/*` matches the route `/v1/users/{id}`. Decided once per route
205/// when the router is built, so a request pays nothing for it.
206///
207/// # Examples
208///
209/// ```
210/// use structured_proxy::transcode::error::ErrorDetailsPolicy;
211///
212/// // Details everywhere except the internal admin surface.
213/// let policy = ErrorDetailsPolicy::default()
214/// .route("/v1/admin/**", false)
215/// .unwrap();
216/// assert!(policy.enabled_for("/v1/users/{id}"));
217/// assert!(!policy.enabled_for("/v1/admin/users/{id}"));
218///
219/// // Off by default, back on for one sub-route.
220/// let policy = ErrorDetailsPolicy::disabled()
221/// .route("/v1/public/**", true)
222/// .unwrap();
223/// assert!(!policy.enabled_for("/v1/users/{id}"));
224/// assert!(policy.enabled_for("/v1/public/items"));
225///
226/// // The opaque-detail extension, off unless switched on, here for one route.
227/// let policy = ErrorDetailsPolicy::default()
228/// .opaque_route("/v1/partner/**", true)
229/// .unwrap();
230/// assert!(!policy.opaque_for("/v1/users/{id}"));
231/// assert!(policy.opaque_for("/v1/partner/orders"));
232/// ```
233#[derive(Debug, Clone)]
234pub struct ErrorDetailsPolicy {
235 enabled: bool,
236 opaque: bool,
237 routes: Vec<RouteRule>,
238}
239
240/// One override: a route glob and the switches it sets. A switch it leaves
241/// `None` is decided by a later rule or the global value.
242#[derive(Debug, Clone)]
243struct RouteRule {
244 matcher: GlobMatcher,
245 enabled: Option<bool>,
246 opaque: Option<bool>,
247}
248
249impl ErrorDetailsPolicy {
250 /// No details on any route, until a [`route`](Self::route) switches them on.
251 pub fn disabled() -> Self {
252 Self {
253 enabled: false,
254 opaque: false,
255 routes: Vec::new(),
256 }
257 }
258
259 /// Add an override for the routes `pattern` matches, checked after the
260 /// ones added before it.
261 ///
262 /// # Errors
263 ///
264 /// A pattern that does not start with `/` (it could never match a route)
265 /// or is not a valid glob.
266 pub fn route(self, pattern: &str, enabled: bool) -> Result<Self, String> {
267 self.rule(pattern, Some(enabled), None)
268 }
269
270 /// Set, for every route without an override, whether a detail whose type
271 /// no descriptor describes goes to `opaqueDetails` (see
272 /// [`StatusDetails::with_opaque_details`]). Off by default: such bytes
273 /// cannot be checked for a nested DebugInfo.
274 pub fn with_opaque_details(mut self, enabled: bool) -> Self {
275 self.opaque = enabled;
276 self
277 }
278
279 /// Add an override of the opaque-detail extension for the routes `pattern`
280 /// matches, checked after the ones added before it.
281 ///
282 /// # Errors
283 ///
284 /// As [`route`](Self::route).
285 pub fn opaque_route(self, pattern: &str, enabled: bool) -> Result<Self, String> {
286 self.rule(pattern, None, Some(enabled))
287 }
288
289 /// Add an override setting either switch or both; the first matching rule
290 /// that sets a switch decides it.
291 ///
292 /// # Errors
293 ///
294 /// As [`route`](Self::route).
295 pub(crate) fn rule(
296 mut self,
297 pattern: &str,
298 enabled: Option<bool>,
299 opaque: Option<bool>,
300 ) -> Result<Self, String> {
301 // Route paths always start with `/`; a relative pattern is a
302 // missing-slash typo that would silently never apply.
303 if !pattern.starts_with('/') {
304 return Err(format!(
305 "error details route pattern {pattern:?} must start with '/'"
306 ));
307 }
308 self.routes.push(RouteRule {
309 matcher: crate::shield::matcher::path_glob(pattern)?,
310 enabled,
311 opaque,
312 });
313 Ok(self)
314 }
315
316 /// Whether the route mounted at `route_path` (axum form, e.g.
317 /// `/v1/users/{id}`) returns details: the first matching rule that sets
318 /// it decides, otherwise the global switch.
319 pub fn enabled_for(&self, route_path: &str) -> bool {
320 self.decide(route_path, |rule| rule.enabled)
321 .unwrap_or(self.enabled)
322 }
323
324 /// Whether the route mounted at `route_path` forwards details of unknown
325 /// types in `opaqueDetails`: the first matching rule that sets it decides,
326 /// otherwise the global switch.
327 pub fn opaque_for(&self, route_path: &str) -> bool {
328 self.decide(route_path, |rule| rule.opaque)
329 .unwrap_or(self.opaque)
330 }
331
332 fn decide(
333 &self,
334 route_path: &str,
335 switch: impl Fn(&RouteRule) -> Option<bool>,
336 ) -> Option<bool> {
337 self.routes
338 .iter()
339 .filter_map(|rule| switch(rule).map(|value| (rule, value)))
340 .find(|(rule, _)| rule.matcher.is_match(route_path))
341 .map(|(_, value)| value)
342 }
343}
344
345impl Default for ErrorDetailsPolicy {
346 /// Details on every route, the opaque-detail extension off.
347 fn default() -> Self {
348 Self {
349 enabled: true,
350 opaque: false,
351 routes: Vec::new(),
352 }
353 }
354}
355
356/// Whether `full_name` is a well-known type with a special ProtoJSON
357/// representation, which an `Any` carries under `value`
358/// (<https://protobuf.dev/programming-guides/json/#any>). The same set
359/// prost-reflect wraps when it serializes an `Any`.
360fn has_special_json(full_name: &str) -> bool {
361 matches!(
362 full_name,
363 "google.protobuf.Any"
364 | "google.protobuf.Timestamp"
365 | "google.protobuf.Duration"
366 | "google.protobuf.Struct"
367 | "google.protobuf.FloatValue"
368 | "google.protobuf.DoubleValue"
369 | "google.protobuf.Int32Value"
370 | "google.protobuf.Int64Value"
371 | "google.protobuf.UInt32Value"
372 | "google.protobuf.UInt64Value"
373 | "google.protobuf.BoolValue"
374 | "google.protobuf.StringValue"
375 | "google.protobuf.BytesValue"
376 | "google.protobuf.FieldMask"
377 | "google.protobuf.ListValue"
378 | "google.protobuf.Value"
379 | "google.protobuf.Empty"
380 )
381}
382
383/// Full name of `google.protobuf.Any`.
384const ANY: &str = "google.protobuf.Any";
385
386/// The type name an `Any.type_url` names, or `None` when the URL is malformed.
387///
388/// The URL must contain a `/`, and the part after the last one is the type's
389/// full name (`google/protobuf/any.proto`). Anything else (a bare name, a
390/// trailing `/`, a query suffix, an empty segment) could disguise a
391/// DebugInfo, so callers refuse the whole status on `None`.
392fn any_type_name(type_url: &str) -> Option<&str> {
393 let (_, name) = type_url.rsplit_once('/')?;
394 is_full_name(name).then_some(name)
395}
396
397/// Whether `name` is a protobuf full name: dot-separated identifiers, each a
398/// letter or `_` followed by letters, digits or `_`.
399fn is_full_name(name: &str) -> bool {
400 !name.is_empty()
401 && name.split('.').all(|part| {
402 let mut chars = part.chars();
403 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
404 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
405 })
406}
407
408/// The rendered details of one status.
409#[derive(Debug, Default)]
410struct RenderedDetails {
411 /// ProtoJSON `Any` entries, in upstream order.
412 details: Vec<Value>,
413 /// Opaque-detail extension entries, in upstream order.
414 opaque: Vec<Value>,
415}
416
417/// An entry of the structured-proxy opaque-detail extension, for a detail whose
418/// type no descriptor describes: `{"index", "typeUrl", "bytes"}`, where `index`
419/// is its position among the forwarded details (so merging `details` and
420/// `opaqueDetails` by position restores the upstream order), `typeUrl` the
421/// original type URL and `bytes` the standard base64 of the original bytes.
422/// It carries no `@type` and lives outside `details`, so it cannot be taken for
423/// a ProtoJSON `Any`.
424fn opaque_entry(index: usize, type_url: &str, value: &[u8]) -> Value {
425 let mut out = Map::with_capacity(3);
426 out.insert("index".into(), index.into());
427 out.insert("typeUrl".into(), type_url.into());
428 out.insert(
429 "bytes".into(),
430 base64::engine::general_purpose::STANDARD
431 .encode(value)
432 .into(),
433 );
434 Value::Object(out)
435}
436
437/// Add to `pool` every top-level message and enum of `canonical` it does not
438/// define, per type rather than per file.
439///
440/// A product may define some canonical types itself: in a file of another name,
441/// or in its own revision of the canonical file holding only a subset. Those
442/// definitions win. The canonical types it lacks still go in, through a copy
443/// of the canonical file renamed under `structured-proxy/canonical/` and
444/// reduced to the missing types; the copy also imports the product files that
445/// define the types it dropped, so its references resolve to the product's
446/// revisions.
447fn complete_with_canonical(pool: &mut DescriptorPool, canonical: &DescriptorPool) {
448 use std::collections::{BTreeSet, HashMap};
449
450 // Canonical file name → the pool files that now provide its types in its
451 // place (its renamed copy, product files defining some of them). A file
452 // importing it imports those instead.
453 let mut stand_ins: HashMap<String, BTreeSet<String>> = HashMap::new();
454 // `files()` lists dependencies before their dependents, so each file finds
455 // its imports already merged.
456 for file in canonical.files() {
457 let mut proto = file.file_descriptor_proto().clone();
458 let package = proto.package().to_owned();
459 let full = |name: &str| {
460 if package.is_empty() {
461 name.to_owned()
462 } else {
463 format!("{package}.{name}")
464 }
465 };
466 // Files of the pool that already define a type of this file.
467 let mut defining_files = BTreeSet::new();
468 proto.message_type.retain(|message| {
469 match pool.get_message_by_name(&full(message.name())) {
470 Some(existing) => {
471 defining_files.insert(existing.parent_file().name().to_owned());
472 false
473 }
474 None => true,
475 }
476 });
477 proto
478 .enum_type
479 .retain(|en| match pool.get_enum_by_name(&full(en.name())) {
480 Some(existing) => {
481 defining_files.insert(existing.parent_file().name().to_owned());
482 false
483 }
484 None => true,
485 });
486 let everything_present = proto.message_type.is_empty() && proto.enum_type.is_empty();
487 let file_present = pool.get_file_by_name(file.name()).is_some();
488 if everything_present {
489 // Typically the very same file. When the types live in files of
490 // other names, importers of this one import those instead.
491 if !file_present {
492 stand_ins.insert(file.name().to_owned(), defining_files);
493 }
494 continue;
495 }
496 // A copy that leaves types out, or whose name the product already uses,
497 // goes in under its own name; importers then need it plus the product
498 // files that define the rest.
499 let renamed = !defining_files.is_empty() || file_present;
500 let mut provided_by = defining_files.clone();
501 if renamed {
502 let name = format!("structured-proxy/canonical/{}", file.name());
503 proto.name = Some(name.clone());
504 provided_by.insert(name);
505 }
506 let mut dependencies = BTreeSet::new();
507 for dep in &proto.dependency {
508 match stand_ins.get(dep) {
509 Some(files) => {
510 // A product revision of the dependency stays importable
511 // next to the copy that completes it.
512 if pool.get_file_by_name(dep).is_some() {
513 dependencies.insert(dep.clone());
514 }
515 dependencies.extend(files.iter().cloned());
516 }
517 None => {
518 dependencies.insert(dep.clone());
519 }
520 }
521 }
522 dependencies.extend(defining_files);
523 proto.dependency = dependencies.into_iter().collect();
524 // Indexes into the old dependency list; nothing here needs them.
525 proto.public_dependency.clear();
526 proto.weak_dependency.clear();
527 proto.source_code_info = None;
528 match pool.add_file_descriptor_proto(proto) {
529 Ok(()) => {
530 if renamed {
531 stand_ins.insert(file.name().to_owned(), provided_by);
532 }
533 }
534 Err(e) => {
535 tracing::debug!(file = %file.name(), "canonical descriptor not merged: {e}");
536 }
537 }
538 }
539}
540
541/// Renders the typed details of a gRPC status (`grpc-status-details-bin`) as
542/// proto3 JSON.
543///
544/// Detail types resolve in one pool: the product descriptors, completed with
545/// the well-known types and the canonical `google/rpc/status.proto` and
546/// `error_details.proto` for whatever the product does not define itself. A
547/// service's own detail messages (and its own `google.rpc` revision) render as
548/// it defines them, the canonical ones are always available even when the
549/// product descriptors do not import them, and a type packed inside another
550/// detail resolves from the same pool as the detail itself.
551///
552/// Details use the canonical proto3 JSON mapping (unset fields omitted, 64-bit
553/// integers as strings), the form clients of the `google.rpc` model expect.
554#[derive(Debug, Clone)]
555pub struct StatusDetails {
556 pool: DescriptorPool,
557 opaque: bool,
558}
559
560impl StatusDetails {
561 /// Build a renderer over `product`, completed with the canonical
562 /// descriptors it lacks. The opaque-detail extension starts off.
563 pub fn new(product: &DescriptorPool) -> Self {
564 let mut canonical = DescriptorPool::global();
565 canonical
566 .decode_file_descriptor_set(tonic_types::pb::FILE_DESCRIPTOR_SET)
567 .expect("tonic-types ships a valid google.rpc descriptor set");
568 // The global pool is process-wide; another crate may have added it.
569 if canonical
570 .get_message_by_name(super::httpbody::HTTP_BODY)
571 .is_none()
572 {
573 canonical
574 .add_file_descriptor_proto(super::httpbody::file_descriptor())
575 .expect("google/api/httpbody.proto is a valid descriptor");
576 }
577 let mut pool = product.clone();
578 complete_with_canonical(&mut pool, &canonical);
579 Self {
580 pool,
581 opaque: false,
582 }
583 }
584
585 /// Whether a detail whose type no descriptor describes goes to
586 /// `opaqueDetails` (`{"index", "typeUrl", "bytes"}` entries: its position
587 /// among the forwarded details, its type URL and the standard base64 of
588 /// its bytes) instead of being withheld. Its
589 /// bytes cannot be inspected, so a DebugInfo it carries in a field would
590 /// reach the client: switch it on only for upstreams trusted not to nest
591 /// one in types the proxy has no descriptor for.
592 pub fn with_opaque_details(mut self, enabled: bool) -> Self {
593 self.opaque = enabled;
594 self
595 }
596
597 /// The details of `status`, `google.rpc.DebugInfo` left out.
598 ///
599 /// A detail whose type resolves goes to `details` in its ProtoJSON `Any`
600 /// form: `@type` plus the message fields, or `@type` plus `value` for a
601 /// well-known type with a special JSON representation. A type no descriptor
602 /// describes has no ProtoJSON form (the mapping requires the type) and its
603 /// bytes cannot be checked for a DebugInfo, so it is withheld, or goes to
604 /// the opaque-detail extension (see [`opaque_entry`]) when switched on.
605 ///
606 /// # Errors
607 ///
608 /// [`MalformedStatus`] when the trailer is not a `google.rpc.Status`, or a
609 /// detail of a known type does not decode or has no valid JSON form. Such a
610 /// detail is never passed on as opaque bytes, since that would present a
611 /// broken value as an unknown one.
612 fn render(&self, status: &tonic::Status) -> Result<RenderedDetails, MalformedStatus> {
613 let mut rendered = RenderedDetails::default();
614 let raw = status.details();
615 if raw.is_empty() {
616 return Ok(rendered);
617 }
618 let decoded = tonic_types::pb::Status::decode(raw).map_err(|e| {
619 tracing::error!("malformed grpc-status-details-bin trailer: {e}");
620 MalformedStatus
621 })?;
622 // The trailer must describe the same error as grpc-status and
623 // grpc-message (gRPC richer error model); otherwise its details would
624 // be attached to an error they were not written for.
625 if decoded.code != status.code() as i32 || decoded.message != status.message() {
626 tracing::error!(
627 trailer_code = decoded.code,
628 status_code = status.code() as i32,
629 "grpc-status-details-bin disagrees with grpc-status / grpc-message"
630 );
631 return Err(MalformedStatus);
632 }
633 rendered.details.reserve(decoded.details.len());
634 // Position among the forwarded details: DebugInfo takes no index, so
635 // the numbering does not reveal that one was withheld.
636 let mut index = 0usize;
637 for any in &decoded.details {
638 let type_url = any.type_url.as_str();
639 let Some(type_name) = any_type_name(type_url) else {
640 tracing::error!(%type_url, "error detail with a malformed type URL");
641 return Err(MalformedStatus);
642 };
643 if type_name == DEBUG_INFO {
644 continue;
645 }
646 match self.resolve(type_name) {
647 Some(desc) => match self.typed_entry(type_url, type_name, desc, &any.value)? {
648 Some(entry) => rendered.details.push(entry),
649 // An Any detail packing DebugInfo, withheld like a direct one.
650 None => continue,
651 },
652 None if self.opaque => {
653 rendered
654 .opaque
655 .push(opaque_entry(index, type_url, &any.value));
656 }
657 None => {
658 tracing::debug!(%type_url, "error detail of an unknown type withheld");
659 continue;
660 }
661 }
662 index += 1;
663 }
664 Ok(rendered)
665 }
666
667 /// The ProtoJSON `Any` form of a detail whose type resolved, with every
668 /// DebugInfo packed inside it removed; `None` when the detail is itself an
669 /// `Any` packing a DebugInfo.
670 fn typed_entry(
671 &self,
672 type_url: &str,
673 type_name: &str,
674 desc: MessageDescriptor,
675 value: &[u8],
676 ) -> Result<Option<Value>, MalformedStatus> {
677 // ProtoJSON puts a well-known type with a special JSON representation
678 // under `value` whatever that JSON looks like (a Struct is an object,
679 // yet still wrapped), so the choice follows the type, not the shape.
680 // Empty is one of them (JSON `{}` in the ProtoJSON table), as in Go's
681 // protojson and prost-reflect, so it is wrapped too.
682 let wrapped = has_special_json(desc.full_name());
683 let mut msg = DynamicMessage::decode(desc, value).map_err(|e| {
684 tracing::error!(detail = %type_name, "undecodable error detail: {e}");
685 MalformedStatus
686 })?;
687 if self.scrub(&mut msg)?.is_none() {
688 return Ok(None);
689 }
690 let json = msg
691 .serialize_with_options(serde_json::value::Serializer, &SerializeOptions::new())
692 .map_err(|e| {
693 tracing::error!(detail = %type_name, "error detail has no valid JSON form: {e}");
694 MalformedStatus
695 })?;
696 let mut out = Map::new();
697 out.insert("@type".into(), type_url.into());
698 match json {
699 Value::Object(fields) if !wrapped => {
700 // A field whose JSON name is `@type` would replace the Any's
701 // own type URL, and there is no faithful ProtoJSON form for it.
702 if fields.contains_key("@type") {
703 tracing::error!(detail = %type_name, "error detail has a field named @type");
704 return Err(MalformedStatus);
705 }
706 out.extend(fields);
707 }
708 other => {
709 out.insert("value".into(), other);
710 }
711 }
712 Ok(Some(Value::Object(out)))
713 }
714
715 fn resolve(&self, type_name: &str) -> Option<MessageDescriptor> {
716 self.pool.get_message_by_name(type_name)
717 }
718
719 /// Remove every DebugInfo anywhere below `msg`: a message field typed as
720 /// DebugInfo, or an `Any` packing one, whether singular, repeated or a map
721 /// value. Only the message types decide, so data such as a `Struct` key
722 /// named `@type` is never mistaken for one. Returns `None` when `msg` is
723 /// itself a DebugInfo or an `Any` packing one (the caller drops it),
724 /// otherwise whether anything changed. A message missing a proto2
725 /// `required` field is [`MalformedStatus`]: decoding accepts it, but it has
726 /// no valid ProtoJSON form.
727 fn scrub(&self, msg: &mut DynamicMessage) -> Result<Option<bool>, MalformedStatus> {
728 let desc = msg.descriptor();
729 if desc.full_name() == ANY {
730 return self.scrub_any(msg);
731 }
732 if desc.full_name() == DEBUG_INFO {
733 return Ok(None);
734 }
735 for field in desc.fields() {
736 if field.cardinality() == Cardinality::Required && !msg.has_field(&field) {
737 tracing::error!(
738 message = %desc.full_name(),
739 field = %field.name(),
740 "error detail lacks a required field"
741 );
742 return Err(MalformedStatus);
743 }
744 }
745 let mut changed = false;
746 let mut dropped_fields = Vec::new();
747 for (field, value) in msg.fields_mut() {
748 if !matches!(field.kind(), Kind::Message(_)) {
749 continue;
750 }
751 if !self.scrub_value(value, &mut changed)? {
752 // A proto2 `required` field cannot be cleared without making
753 // the message invalid, so the whole message goes instead.
754 if field.cardinality() == Cardinality::Required {
755 return Ok(None);
756 }
757 dropped_fields.push(field);
758 }
759 }
760 // Extensions are not among the message's own fields, yet ProtoJSON
761 // renders them (as `[full.name]`), so they are scrubbed the same way.
762 let mut dropped_extensions = Vec::new();
763 for (extension, value) in msg.extensions_mut() {
764 if !matches!(extension.kind(), Kind::Message(_)) {
765 continue;
766 }
767 if !self.scrub_value(value, &mut changed)? {
768 dropped_extensions.push(extension);
769 }
770 }
771 for field in dropped_fields {
772 msg.clear_field(&field);
773 changed = true;
774 }
775 for extension in dropped_extensions {
776 msg.clear_extension(&extension);
777 changed = true;
778 }
779 Ok(Some(changed))
780 }
781
782 /// [`scrub`](Self::scrub) applied to one set field or extension value:
783 /// repeated elements and map entries that must go are removed in place.
784 /// Returns `false` when the value is a singular message that must go, so
785 /// the caller clears the field.
786 fn scrub_value(
787 &self,
788 value: &mut PbValue,
789 changed: &mut bool,
790 ) -> Result<bool, MalformedStatus> {
791 match value {
792 PbValue::Message(inner) => match self.scrub(inner)? {
793 Some(inner_changed) => *changed |= inner_changed,
794 None => return Ok(false),
795 },
796 PbValue::List(items) => {
797 let before = items.len();
798 let mut failed = None;
799 items.retain_mut(|item| match item {
800 PbValue::Message(inner) if failed.is_none() => match self.scrub(inner) {
801 Ok(Some(inner_changed)) => {
802 *changed |= inner_changed;
803 true
804 }
805 Ok(None) => false,
806 Err(e) => {
807 failed = Some(e);
808 true
809 }
810 },
811 _ => true,
812 });
813 if let Some(e) = failed {
814 return Err(e);
815 }
816 *changed |= items.len() != before;
817 }
818 PbValue::Map(entries) => {
819 let mut dropped = Vec::new();
820 for (key, value) in entries.iter_mut() {
821 if let PbValue::Message(inner) = value {
822 match self.scrub(inner)? {
823 Some(inner_changed) => *changed |= inner_changed,
824 None => dropped.push(key.clone()),
825 }
826 }
827 }
828 for key in dropped {
829 entries.remove(&key);
830 *changed = true;
831 }
832 }
833 _ => {}
834 }
835 Ok(true)
836 }
837
838 /// [`scrub`](Self::scrub) for an `Any`: its type URL is checked like a
839 /// top-level detail's, a DebugInfo is reported for dropping, and a packed
840 /// message of a known type is scrubbed and re-packed if it changed.
841 fn scrub_any(&self, any: &mut DynamicMessage) -> Result<Option<bool>, MalformedStatus> {
842 let type_url = match any.get_field_by_name("type_url").as_deref() {
843 Some(PbValue::String(url)) => url.clone(),
844 _ => String::new(),
845 };
846 let Some(type_name) = any_type_name(&type_url) else {
847 tracing::error!(%type_url, "packed Any with a malformed type URL");
848 return Err(MalformedStatus);
849 };
850 if type_name == DEBUG_INFO {
851 return Ok(None);
852 }
853 // An unknown packed type has no JSON form; serialization reports it.
854 let Some(desc) = self.resolve(type_name) else {
855 return Ok(Some(false));
856 };
857 let bytes = match any.get_field_by_name("value").as_deref() {
858 Some(PbValue::Bytes(bytes)) => bytes.clone(),
859 _ => Default::default(),
860 };
861 let mut inner = DynamicMessage::decode(desc, bytes).map_err(|e| {
862 tracing::error!(detail = %type_name, "undecodable packed Any: {e}");
863 MalformedStatus
864 })?;
865 match self.scrub(&mut inner)? {
866 None => Ok(None),
867 Some(false) => Ok(Some(false)),
868 Some(true) => {
869 any.set_field_by_name("value", PbValue::Bytes(inner.encode_to_vec().into()));
870 Ok(Some(true))
871 }
872 }
873 }
874}
875
876#[cfg(test)]
877mod tests;