use alloc::boxed::Box;
use alloc::string::String;
use alloc::vec::Vec;
use serde_json::Value;
use crate::error::{ErrorIterator, ValidationError, ValidationErrorBuilder, ValidationErrorKind};
use crate::paths::{LazyLocation, Location};
use super::{Validate, ValidationContext};
pub enum ContentEncoding {
Base64,
QuotedPrintable,
Unknown(String),
}
impl ContentEncoding {
#[must_use]
pub fn from_name(name: &str) -> Self {
match name {
"base64" => Self::Base64,
"quoted-printable" => Self::QuotedPrintable,
other => Self::Unknown(other.to_string()),
}
}
pub fn decode(&self, s: &str) -> Option<Vec<u8>> {
match self {
Self::Base64 => decode_base64(s),
Self::QuotedPrintable => decode_quoted_printable(s),
Self::Unknown(_) => None,
}
}
pub fn is_valid(&self, s: &str) -> bool {
match self {
Self::Base64 => is_valid_base64(s),
Self::QuotedPrintable => is_valid_quoted_printable(s),
Self::Unknown(_) => true,
}
}
}
pub struct ContentEncodingValidator {
encoding: ContentEncoding,
schema_path: Location,
}
impl ContentEncodingValidator {
#[must_use]
pub fn new(encoding: &str, schema_path: Location) -> Self {
Self {
encoding: ContentEncoding::from_name(encoding),
schema_path,
}
}
}
impl Validate for ContentEncodingValidator {
fn is_valid(&self, instance: &Value, _ctx: &mut ValidationContext) -> bool {
let Value::String(s) = instance else {
return true;
};
self.encoding.is_valid(s)
}
fn validate(
&self,
instance: &Value,
instance_path: &LazyLocation<'_>,
ctx: &mut ValidationContext,
) -> Result<(), ValidationError> {
if self.is_valid(instance, ctx) {
Ok(())
} else {
let encoding_name = match &self.encoding {
ContentEncoding::Base64 => "base64",
ContentEncoding::QuotedPrintable => "quoted-printable",
ContentEncoding::Unknown(name) => name.as_str(),
};
Err(
ValidationErrorBuilder::new(instance_path.materialize(), self.schema_path.clone())
.build(ValidationErrorKind::ContentEncoding {
encoding: encoding_name.to_string(),
}),
)
}
}
fn iter_errors(
&self,
instance: &Value,
instance_path: &LazyLocation<'_>,
ctx: &mut ValidationContext,
) -> ErrorIterator {
if self.is_valid(instance, ctx) {
Box::new(core::iter::empty())
} else {
let encoding_name = match &self.encoding {
ContentEncoding::Base64 => "base64",
ContentEncoding::QuotedPrintable => "quoted-printable",
ContentEncoding::Unknown(name) => name.as_str(),
};
let err =
ValidationErrorBuilder::new(instance_path.materialize(), self.schema_path.clone())
.build(ValidationErrorKind::ContentEncoding {
encoding: encoding_name.to_string(),
});
Box::new(core::iter::once(err))
}
}
}
pub struct ContentMediaTypeValidator {
media_type: String,
schema_path: Location,
}
impl ContentMediaTypeValidator {
#[must_use]
pub fn new(media_type: String, schema_path: Location) -> Self {
Self {
media_type,
schema_path,
}
}
}
impl Validate for ContentMediaTypeValidator {
fn is_valid(&self, instance: &Value, _ctx: &mut ValidationContext) -> bool {
let Value::String(s) = instance else {
return true;
};
match self.media_type.as_str() {
"application/json" => serde_json::from_str::<Value>(s).is_ok(),
_ => true,
}
}
fn validate(
&self,
instance: &Value,
instance_path: &LazyLocation<'_>,
ctx: &mut ValidationContext,
) -> Result<(), ValidationError> {
if self.is_valid(instance, ctx) {
Ok(())
} else {
Err(
ValidationErrorBuilder::new(instance_path.materialize(), self.schema_path.clone())
.build(ValidationErrorKind::ContentMediaType {
media_type: self.media_type.clone(),
}),
)
}
}
fn iter_errors(
&self,
instance: &Value,
instance_path: &LazyLocation<'_>,
ctx: &mut ValidationContext,
) -> ErrorIterator {
if self.is_valid(instance, ctx) {
Box::new(core::iter::empty())
} else {
let err =
ValidationErrorBuilder::new(instance_path.materialize(), self.schema_path.clone())
.build(ValidationErrorKind::ContentMediaType {
media_type: self.media_type.clone(),
});
Box::new(core::iter::once(err))
}
}
}
pub struct ContentCombinedValidator {
encoding: ContentEncoding,
media_type: String,
encoding_schema_path: Location,
media_type_schema_path: Location,
}
impl ContentCombinedValidator {
#[must_use]
pub fn new(
encoding: ContentEncoding,
media_type: String,
encoding_schema_path: Location,
media_type_schema_path: Location,
) -> Self {
Self {
encoding,
media_type,
encoding_schema_path,
media_type_schema_path,
}
}
}
impl Validate for ContentCombinedValidator {
fn is_valid(&self, instance: &Value, _ctx: &mut ValidationContext) -> bool {
let Value::String(s) = instance else {
return true;
};
if matches!(self.encoding, ContentEncoding::Unknown(_)) {
return self.is_media_type_valid(s);
}
let Some(decoded) = self.encoding.decode(s) else {
return false;
};
let Ok(decoded_str) = core::str::from_utf8(&decoded) else {
return false;
};
self.is_media_type_valid(decoded_str)
}
fn validate(
&self,
instance: &Value,
instance_path: &LazyLocation<'_>,
_ctx: &mut ValidationContext,
) -> Result<(), ValidationError> {
let Value::String(s) = instance else {
return Ok(());
};
if matches!(self.encoding, ContentEncoding::Unknown(_)) {
if self.is_media_type_valid(s) {
return Ok(());
}
return Err(ValidationErrorBuilder::new(
instance_path.materialize(),
self.media_type_schema_path.clone(),
)
.build(ValidationErrorKind::ContentMediaType {
media_type: self.media_type.clone(),
}));
}
let Some(decoded) = self.encoding.decode(s) else {
return Err(ValidationErrorBuilder::new(
instance_path.materialize(),
self.encoding_schema_path.clone(),
)
.build(ValidationErrorKind::ContentEncoding {
encoding: match &self.encoding {
ContentEncoding::Base64 => "base64",
ContentEncoding::QuotedPrintable => "quoted-printable",
ContentEncoding::Unknown(_) => unreachable!(),
}
.to_string(),
}));
};
let Ok(decoded_str) = core::str::from_utf8(&decoded) else {
return Err(ValidationErrorBuilder::new(
instance_path.materialize(),
self.encoding_schema_path.clone(),
)
.build(ValidationErrorKind::ContentEncoding {
encoding: "base64".to_string(),
}));
};
if self.is_media_type_valid(decoded_str) {
Ok(())
} else {
Err(ValidationErrorBuilder::new(
instance_path.materialize(),
self.media_type_schema_path.clone(),
)
.build(ValidationErrorKind::ContentMediaType {
media_type: self.media_type.clone(),
}))
}
}
fn iter_errors(
&self,
instance: &Value,
instance_path: &LazyLocation<'_>,
ctx: &mut ValidationContext,
) -> ErrorIterator {
match self.validate(instance, instance_path, ctx) {
Ok(()) => Box::new(core::iter::empty()),
Err(e) => Box::new(core::iter::once(e)),
}
}
}
impl ContentCombinedValidator {
fn is_media_type_valid(&self, s: &str) -> bool {
match self.media_type.as_str() {
"application/json" => serde_json::from_str::<Value>(s).is_ok(),
_ => true,
}
}
}
fn is_valid_base64(s: &str) -> bool {
if s.is_empty() {
return true;
}
if !s.len().is_multiple_of(4) {
return false;
}
let bytes = s.as_bytes();
let len = bytes.len();
let pad = if bytes[len - 1] == b'=' {
if len >= 2 && bytes[len - 2] == b'=' {
2
} else {
1
}
} else {
0
};
let data_end = len - pad;
bytes[..data_end]
.iter()
.all(|&b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/'))
}
fn is_valid_quoted_printable(s: &str) -> bool {
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '=' {
if let Some(&'\r') = chars.peek() {
chars.next();
if let Some(&'\n') = chars.peek() {
chars.next();
} else {
return false; }
} else if let Some(&'\n') = chars.peek() {
chars.next();
} else {
let hex1 = chars.next();
let hex2 = chars.next();
match (hex1, hex2) {
(Some(h1), Some(h2)) if h1.is_ascii_hexdigit() && h2.is_ascii_hexdigit() => {}
_ => return false,
}
}
} else if !('!'..='~').contains(&c) {
if c != ' ' && c != '\t' {
return false;
}
}
}
true
}
#[allow(
clippy::many_single_char_names,
clippy::cast_possible_truncation,
clippy::cast_lossless,
clippy::unnecessary_cast
)]
fn decode_base64(s: &str) -> Option<Vec<u8>> {
if s.is_empty() {
return Some(Vec::new());
}
if !s.len().is_multiple_of(4) {
return None;
}
let bytes = s.as_bytes();
let len = bytes.len();
let pad = if bytes[len - 1] == b'=' {
if len >= 2 && bytes[len - 2] == b'=' {
2
} else {
1
}
} else {
0
};
let data_end = len - pad;
for &b in &bytes[..data_end] {
if !matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/') {
return None;
}
}
let mut result = Vec::with_capacity((data_end / 4) * 3);
let mut idx = 0;
while idx + 4 <= data_end {
let w = b64_val(bytes[idx])? << 18
| b64_val(bytes[idx + 1])? << 12
| b64_val(bytes[idx + 2])? << 6
| b64_val(bytes[idx + 3])?;
result.push((w >> 16) as u8);
result.push((w >> 8) as u8);
result.push(w as u8);
idx += 4;
}
if data_end < len {
let data_count = 4 - (len - data_end);
if idx + data_count <= data_end {
let a = b64_val(bytes[idx])?;
let b = if data_count > 1 {
b64_val(bytes[idx + 1])?
} else {
0
};
let w = a << 18 | b << 12;
result.push((w >> 16) as u8);
if data_count > 2 {
result.push((w >> 8) as u8);
}
}
}
Some(result)
}
#[allow(clippy::cast_lossless)]
fn b64_val(c: u8) -> Option<u32> {
Some(match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a' + 26),
b'0'..=b'9' => u32::from(c - b'0' + 52),
b'+' => 62,
b'/' => 63,
b'=' => 0,
_ => return None,
})
}
#[allow(clippy::uninlined_format_args)]
fn decode_quoted_printable(s: &str) -> Option<Vec<u8>> {
let mut result = Vec::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '=' {
if let Some(&'\r') = chars.peek() {
chars.next();
if let Some(&'\n') = chars.peek() {
chars.next();
} else {
return None;
}
} else if let Some(&'\n') = chars.peek() {
chars.next();
} else {
let h1 = chars.next()?;
let h2 = chars.next()?;
let byte = u8::from_str_radix(&alloc::format!("{h1}{h2}"), 16).ok()?;
result.push(byte);
}
} else if ('!'..='~').contains(&c) || c == ' ' || c == '\t' {
result.push(c as u8);
} else {
return None;
}
}
Some(result)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ctx() -> ValidationContext {
ValidationContext::new()
}
#[test]
fn valid_base64_standard() {
assert!(is_valid_base64("SGVsbG8="));
assert!(is_valid_base64("SGVsbG8gV29ybGQ="));
assert!(is_valid_base64("AAAA"));
}
#[test]
fn valid_base64_empty() {
assert!(is_valid_base64(""));
}
#[test]
fn valid_base64_padding() {
assert!(is_valid_base64("YWI=")); assert!(is_valid_base64("YQ==")); }
#[test]
fn invalid_base64_wrong_length() {
assert!(!is_valid_base64("SGVsbG8")); assert!(!is_valid_base64("A"));
}
#[test]
fn valid_base64_lowercase() {
assert!(is_valid_base64("aaaa")); }
#[test]
fn invalid_base64_bad_padding() {
assert!(!is_valid_base64("A===")); }
#[test]
fn invalid_base64_bad_chars() {
assert!(!is_valid_base64("SGVs!G8=")); assert!(!is_valid_base64("SGVs bG8=")); }
#[test]
fn valid_qp_simple() {
assert!(is_valid_quoted_printable("Hello=20World"));
assert!(is_valid_quoted_printable("=48=65=6C=6C=6F"));
}
#[test]
fn valid_qp_soft_linebreak() {
assert!(is_valid_quoted_printable("Hello=\r\nWorld"));
assert!(is_valid_quoted_printable("Hello=\nWorld"));
}
#[test]
fn invalid_qp_incomplete_hex() {
assert!(!is_valid_quoted_printable("Hello=2"));
assert!(!is_valid_quoted_printable("Hello="));
}
#[test]
fn content_encoding_base64_valid() {
let v = ContentEncodingValidator::new("base64", Location::new());
assert!(v.is_valid(&json!("SGVsbG8="), &mut ctx()));
}
#[test]
fn content_encoding_base64_invalid() {
let v = ContentEncodingValidator::new("base64", Location::new());
assert!(!v.is_valid(&json!("not-valid!!!"), &mut ctx()));
}
#[test]
fn content_encoding_non_string_always_valid() {
let v = ContentEncodingValidator::new("base64", Location::new());
assert!(v.is_valid(&json!(42), &mut ctx()));
}
#[test]
fn content_encoding_unknown_passes() {
let v = ContentEncodingValidator::new("rot13", Location::new());
assert!(v.is_valid(&json!("anything"), &mut ctx()));
}
#[test]
fn content_media_type_json_valid() {
let v = ContentMediaTypeValidator::new("application/json".into(), Location::new());
assert!(v.is_valid(&json!({"key": "value"}), &mut ctx()));
}
#[test]
fn content_media_type_json_invalid() {
let v = ContentMediaTypeValidator::new("application/json".into(), Location::new());
assert!(!v.is_valid(&json!("not json"), &mut ctx()));
}
#[test]
fn content_media_type_unknown_passes() {
let v = ContentMediaTypeValidator::new("text/plain".into(), Location::new());
assert!(v.is_valid(&json!("anything"), &mut ctx()));
}
#[test]
fn combined_base64_json_valid() {
let encoded = "eyJhbnN3ZXIiOjQyfQ==";
let v = ContentCombinedValidator::new(
ContentEncoding::Base64,
"application/json".into(),
Location::new(),
Location::new(),
);
assert!(v.is_valid(&json!(encoded), &mut ctx()));
}
#[test]
fn combined_base64_json_invalid() {
let encoded = "bm90IGpzb24=";
let v = ContentCombinedValidator::new(
ContentEncoding::Base64,
"application/json".into(),
Location::new(),
Location::new(),
);
assert!(!v.is_valid(&json!(encoded), &mut ctx()));
}
#[test]
fn combined_bad_encoding() {
let v = ContentCombinedValidator::new(
ContentEncoding::Base64,
"application/json".into(),
Location::new(),
Location::new(),
);
assert!(!v.is_valid(&json!("not-base64!"), &mut ctx()));
}
}