Skip to main content

protovalidate_buffa/
connect.rs

1use std::fmt;
2
3use base64::{
4    Engine as _,
5    engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
6};
7use buffa::{Message, MessageName};
8use connectrpc::{ConnectError, ErrorDetail};
9
10use crate::{
11    ValidationError, Violation,
12    error_proto::{Exposure, convert_violation},
13    proto,
14};
15
16// Library policy, not a protocol limit. 4 KiB becomes at most 5464 base64
17// bytes, leaving headroom for Status/Any and other metadata under the common
18// 8-KiB limit: https://grpc.io/docs/guides/metadata/. Other headers/details
19// still count; this cannot guarantee the whole metadata block fits.
20const MAX_DETAIL_BYTES: usize = 4096;
21const MAX_BASE64_BYTES: usize = MAX_DETAIL_BYTES.div_ceil(3) * 4;
22// A separate allocation budget prevents tiny repeated messages from expanding
23// without bound. 1 MiB accommodates the generated elements in a 4-KiB payload.
24const MAX_ELEMENT_MEMORY: usize = 1024 * 1024;
25const INVALID_MESSAGE: &str = "request validation failed";
26const TRUNCATED_MESSAGE: &str = "request validation failed (violation details truncated)";
27
28impl ValidationError {
29    /// Converts an RPC **request** validation failure into a transport error.
30    ///
31    /// Requires `connect`. Violations produce `invalid_argument` with one
32    /// canonical [`proto::Violations`] detail. Compilation/evaluation failures
33    /// take precedence (including mixed states), producing `internal` with no
34    /// details. An empty error is also `internal`.
35    ///
36    /// Public messages are fixed. Detail messages and all map keys are omitted;
37    /// rule IDs, schema names/numbers/types, repeated indexes and `for_key` are
38    /// retained. Without map selectors, paths identify schema locations but
39    /// cannot identify a particular map entry. Schema names and rule IDs are
40    /// assumed to be schema-authored, not populated with rejected values.
41    ///
42    /// Details contain a prefix of whole violations whose combined protobuf
43    /// encoding is at most 4096 bytes. If the next violation cannot fit, it and
44    /// all subsequent violations are omitted, and the public message indicates
45    /// truncation. No detail is attached if none fit. This leaves gRPC envelope
46    /// and base64 headroom, but is not a limit on the complete metadata block.
47    /// Callers adding metadata/details must budget those separately.
48    ///
49    /// The original error is retained as `std::error::Error::source()` and is
50    /// never serialized. It, and [`Self::to_proto`], may contain rejected data.
51    /// Response validation is not performed by `connect_impl`; callers that
52    /// validate server responses should use a generic `ConnectError::internal`
53    /// with the diagnostic as its source instead of this request conversion.
54    ///
55    /// ```
56    /// use protovalidate_buffa::ValidationError;
57    /// use connectrpc::ErrorCode;
58    /// let failure = ValidationError {
59    ///     compile_error: Some("private diagnostic".into()),
60    ///     ..Default::default()
61    /// };
62    /// let rpc_error = failure.into_connect_error();
63    /// assert_eq!(rpc_error.code, ErrorCode::Internal);
64    /// assert!(rpc_error.details.is_empty());
65    /// ```
66    #[must_use]
67    pub fn into_connect_error(self) -> ConnectError {
68        if self.compile_error.is_some()
69            || self.runtime_error.is_some()
70            || self.violations.is_empty()
71        {
72            return ConnectError::internal("validation failed").with_source(self);
73        }
74
75        let mut details = proto::Violations::default();
76        for violation in &self.violations {
77            // Reject large schema strings/path lists before copying. Messages
78            // and keys are never copied into this public representation.
79            if !fits_copy_budget(violation) {
80                break;
81            }
82            details
83                .violations
84                .push(convert_violation(violation, &Exposure::Public));
85            if details.encoded_len() as usize > MAX_DETAIL_BYTES {
86                details.violations.pop();
87                break;
88            }
89        }
90        let message = if details.violations.len() == self.violations.len() {
91            INVALID_MESSAGE
92        } else {
93            TRUNCATED_MESSAGE
94        };
95        let mut error = ConnectError::invalid_argument(message);
96        if !details.violations.is_empty() {
97            error = error.with_detail(ErrorDetail::from_message(
98                proto::Violations::FULL_NAME,
99                &details,
100            ));
101        }
102        error.with_source(self)
103    }
104}
105
106fn fits_copy_budget(violation: &Violation) -> bool {
107    let Some(mut remaining) = MAX_DETAIL_BYTES.checked_sub(violation.rule_id.len()) else {
108        return false;
109    };
110    for path in [&violation.field, &violation.rule] {
111        // Each element requires at least its tag and length on the wire.
112        if path.elements.len() > remaining / 2 {
113            return false;
114        }
115        remaining -= path.elements.len() * 2;
116        for element in &path.elements {
117            let name_len = element.field_name.as_ref().map_or(0, |name| name.len());
118            let Some(rest) = remaining.checked_sub(name_len) else {
119                return false;
120            };
121            remaining = rest;
122        }
123    }
124    true
125}
126
127/// A matching validation detail could not be decoded within receiver limits.
128///
129/// Returned by [`decode_violations`]. Its diagnostic contains no peer data.
130///
131/// ```
132/// use protovalidate_buffa::decode_violations;
133/// let detail = connectrpc::ErrorDetail {
134///     type_url: "buf.validate.Violations".into(),
135///     value: None,
136///     debug: None,
137/// };
138/// let error = decode_violations(&detail).unwrap_err();
139/// assert_eq!(error.to_string(), "validation detail has no value");
140/// ```
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct DecodeViolationsError {
143    reason: &'static str,
144}
145
146impl fmt::Display for DecodeViolationsError {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.write_str(self.reason)
149    }
150}
151
152impl std::error::Error for DecodeViolationsError {}
153
154/// Decodes a canonical validation detail from a Connect or gRPC error.
155///
156/// Requires `connect`. Accepts the bare protobuf name or a type URL whose last
157/// segment is `buf.validate.Violations`, including `type.googleapis.com/`.
158/// Returns `Ok(None)` for unrelated types; never uses the optional JSON debug
159/// value. Padded and unpadded standard base64 are accepted.
160///
161/// Receiver limits are 4096 decoded bytes, 5464 base64 bytes (checked before
162/// allocation), and 1 MiB of repeated/map element memory through buffa's decode
163/// options. Its default recursion and unknown-field limits also apply. Unknown
164/// protobuf fields are accepted. Peer messages and map keys are preserved;
165/// decoding does not impose this library's sender redaction policy.
166///
167/// # Errors
168///
169/// Returns [`DecodeViolationsError`] for a matching detail with a missing value,
170/// invalid base64/protobuf, exceeded limits, or no violations. A decoded detail
171/// describes failures; it does not establish the enclosing RPC status. Callers
172/// should inspect `ConnectError::code` as well.
173///
174/// ```
175/// use protovalidate_buffa::{decode_violations, proto};
176/// let message = proto::Violations {
177///     violations: vec![proto::Violation {
178///         rule_id: Some("string.min_len".into()),
179///         ..Default::default()
180///     }],
181///     ..Default::default()
182/// };
183/// let detail = connectrpc::ErrorDetail::from_message("buf.validate.Violations", &message);
184/// let decoded = decode_violations(&detail)?.unwrap();
185/// assert_eq!(decoded.violations[0].rule_id.as_deref(), Some("string.min_len"));
186/// # Ok::<(), protovalidate_buffa::DecodeViolationsError>(())
187/// ```
188pub fn decode_violations(
189    detail: &ErrorDetail,
190) -> Result<Option<proto::Violations>, DecodeViolationsError> {
191    let name = detail.type_url.rsplit('/').next().unwrap_or_default();
192    if name != proto::Violations::FULL_NAME {
193        return Ok(None);
194    }
195    let value = detail.value.as_deref().ok_or(DecodeViolationsError {
196        reason: "validation detail has no value",
197    })?;
198    if value.len() > MAX_BASE64_BYTES {
199        return Err(DecodeViolationsError {
200            reason: "validation detail exceeds size limit",
201        });
202    }
203    let bytes = STANDARD_NO_PAD
204        .decode(value)
205        .or_else(|_| STANDARD.decode(value))
206        .map_err(|_| DecodeViolationsError {
207            reason: "validation detail has invalid base64",
208        })?;
209    let violations: proto::Violations = buffa::DecodeOptions::new()
210        .with_max_message_size(MAX_DETAIL_BYTES)
211        .with_element_memory_limit(MAX_ELEMENT_MEMORY)
212        .decode_from_slice(&bytes)
213        .map_err(|_| DecodeViolationsError {
214            reason: "validation detail has invalid protobuf or exceeds decode limits",
215        })?;
216    if violations.violations.is_empty() {
217        return Err(DecodeViolationsError {
218            reason: "validation detail contains no violations",
219        });
220    }
221    Ok(Some(violations))
222}