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}