use std::fmt;
pub const MAX_SAFE_TEXT_CHARS: usize = 512;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SafeText(String);
impl SafeText {
pub fn from_static(literal: &'static str) -> Self {
SafeText(literal.to_owned())
}
pub fn from_untrusted(text: impl AsRef<str>) -> Self {
SafeText(sanitize(text.as_ref(), MAX_SAFE_TEXT_CHARS))
}
pub fn describing_json_error(error: &serde_json::Error) -> Self {
use serde_json::error::Category;
let what = match error.classify() {
Category::Io => "i/o error while reading",
Category::Syntax => "syntax error: not well-formed",
Category::Data => "data error: well-formed but not the expected shape",
Category::Eof => "unexpected end of input",
};
SafeText(format!(
"{what} (at line {}, column {}; the offending input is withheld because it is \
peer-supplied)",
error.line(),
error.column()
))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SafeText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Debug for SafeText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self.0)
}
}
impl From<&'static str> for SafeText {
fn from(literal: &'static str) -> Self {
SafeText::from_static(literal)
}
}
fn sanitize(text: &str, max_chars: usize) -> String {
let mut out = String::with_capacity(text.len().min(max_chars));
for (count, ch) in text.chars().enumerate() {
if count == max_chars {
out.push_str("…<truncated>");
break;
}
if ch.is_control() || is_bidi_control(ch) {
out.extend(ch.escape_debug());
} else {
out.push(ch);
}
}
out
}
fn is_bidi_control(ch: char) -> bool {
matches!(
ch,
'\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_newline_cannot_survive_as_a_line_break() {
let text = SafeText::from_untrusted("benign\n2026-07-31 ERROR forged");
assert!(!text.as_str().contains('\n'));
assert!(text.as_str().contains("forged"), "escaped, not deleted");
}
#[test]
fn a_carriage_return_and_a_nul_are_escaped_too() {
let text = SafeText::from_untrusted("a\r\0b");
assert!(!text.as_str().contains('\r'));
assert!(!text.as_str().contains('\0'));
}
#[test]
fn bidi_overrides_are_neutralized() {
for ch in ['\u{202E}', '\u{2066}', '\u{200F}', '\u{061C}'] {
let text = SafeText::from_untrusted(format!("safe{ch}txt"));
assert!(
!text.as_str().contains(ch),
"{ch:?} reached the output and can reorder the line"
);
}
}
#[test]
fn text_at_the_bound_is_kept_whole() {
let text = SafeText::from_untrusted("x".repeat(MAX_SAFE_TEXT_CHARS));
assert_eq!(text.as_str().chars().count(), MAX_SAFE_TEXT_CHARS);
assert!(!text.as_str().contains("truncated"));
}
#[test]
fn one_character_past_the_bound_is_truncated() {
let text = SafeText::from_untrusted("x".repeat(MAX_SAFE_TEXT_CHARS + 1));
assert!(text.as_str().contains("truncated"));
assert_eq!(text.as_str().matches('x').count(), MAX_SAFE_TEXT_CHARS);
}
#[test]
fn escaping_cannot_multiply_a_flood_past_the_bound() {
let text = SafeText::from_untrusted("\n".repeat(MAX_SAFE_TEXT_CHARS * 4));
assert!(text.as_str().chars().count() <= MAX_SAFE_TEXT_CHARS * 2 + 16);
}
#[test]
fn debug_does_not_leak_what_display_neutralized() {
let text = SafeText::from_untrusted("a\nb");
assert!(!format!("{text:?}").contains('\n'));
}
#[test]
fn a_json_error_describes_the_failure_without_quoting_the_input() {
let err = serde_json::from_str::<u64>(r#""stranger-supplied-value""#)
.expect_err("a string is not a u64");
let described = SafeText::describing_json_error(&err);
assert!(
err.to_string().contains("stranger-supplied-value"),
"premise: serde echoes"
);
assert!(!described.as_str().contains("stranger-supplied-value"));
assert!(described.as_str().contains("data error"));
assert!(described.as_str().contains("line 1"));
}
#[test]
fn a_json_error_distinguishes_malformed_from_wrong_shape() {
let syntax = serde_json::from_str::<u64>("@@@").expect_err("malformed");
let data = serde_json::from_str::<u64>(r#""7""#).expect_err("wrong shape");
assert!(SafeText::describing_json_error(&syntax)
.as_str()
.contains("syntax error"));
assert!(SafeText::describing_json_error(&data)
.as_str()
.contains("data error"));
}
}