use std::fmt;
use base64::{
Engine as _,
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
};
use buffa::{Message, MessageName};
use connectrpc::{ConnectError, ErrorDetail};
use crate::{
ValidationError, Violation,
error_proto::{Exposure, convert_violation},
proto,
};
const MAX_DETAIL_BYTES: usize = 4096;
const MAX_BASE64_BYTES: usize = MAX_DETAIL_BYTES.div_ceil(3) * 4;
const MAX_ELEMENT_MEMORY: usize = 1024 * 1024;
const INVALID_MESSAGE: &str = "request validation failed";
const TRUNCATED_MESSAGE: &str = "request validation failed (violation details truncated)";
impl ValidationError {
#[must_use]
pub fn into_connect_error(self) -> ConnectError {
if self.compile_error.is_some()
|| self.runtime_error.is_some()
|| self.violations.is_empty()
{
return ConnectError::internal("validation failed").with_source(self);
}
let mut details = proto::Violations::default();
for violation in &self.violations {
if !fits_copy_budget(violation) {
break;
}
details
.violations
.push(convert_violation(violation, &Exposure::Public));
if details.encoded_len() as usize > MAX_DETAIL_BYTES {
details.violations.pop();
break;
}
}
let message = if details.violations.len() == self.violations.len() {
INVALID_MESSAGE
} else {
TRUNCATED_MESSAGE
};
let mut error = ConnectError::invalid_argument(message);
if !details.violations.is_empty() {
error = error.with_detail(ErrorDetail::from_message(
proto::Violations::FULL_NAME,
&details,
));
}
error.with_source(self)
}
}
fn fits_copy_budget(violation: &Violation) -> bool {
let Some(mut remaining) = MAX_DETAIL_BYTES.checked_sub(violation.rule_id.len()) else {
return false;
};
for path in [&violation.field, &violation.rule] {
if path.elements.len() > remaining / 2 {
return false;
}
remaining -= path.elements.len() * 2;
for element in &path.elements {
let name_len = element.field_name.as_ref().map_or(0, |name| name.len());
let Some(rest) = remaining.checked_sub(name_len) else {
return false;
};
remaining = rest;
}
}
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodeViolationsError {
reason: &'static str,
}
impl fmt::Display for DecodeViolationsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.reason)
}
}
impl std::error::Error for DecodeViolationsError {}
pub fn decode_violations(
detail: &ErrorDetail,
) -> Result<Option<proto::Violations>, DecodeViolationsError> {
let name = detail.type_url.rsplit('/').next().unwrap_or_default();
if name != proto::Violations::FULL_NAME {
return Ok(None);
}
let value = detail.value.as_deref().ok_or(DecodeViolationsError {
reason: "validation detail has no value",
})?;
if value.len() > MAX_BASE64_BYTES {
return Err(DecodeViolationsError {
reason: "validation detail exceeds size limit",
});
}
let bytes = STANDARD_NO_PAD
.decode(value)
.or_else(|_| STANDARD.decode(value))
.map_err(|_| DecodeViolationsError {
reason: "validation detail has invalid base64",
})?;
let violations: proto::Violations = buffa::DecodeOptions::new()
.with_max_message_size(MAX_DETAIL_BYTES)
.with_element_memory_limit(MAX_ELEMENT_MEMORY)
.decode_from_slice(&bytes)
.map_err(|_| DecodeViolationsError {
reason: "validation detail has invalid protobuf or exceeds decode limits",
})?;
if violations.violations.is_empty() {
return Err(DecodeViolationsError {
reason: "validation detail contains no violations",
});
}
Ok(Some(violations))
}