use bacnet_types::text::breaks_line;
use serde_json::{Value, json};
pub(crate) fn envelope(text: &str, source: impl Into<String>, cap: usize) -> Value {
let (text, truncated) = clean(text, cap);
json!({ "untrusted": text, "source": source.into(), "truncated": truncated })
}
pub(crate) fn envelope_cleaned(text: &str, truncated: bool, source: impl Into<String>) -> Value {
json!({ "untrusted": text, "source": source.into(), "truncated": truncated })
}
pub(crate) fn clean(text: &str, cap: usize) -> (String, bool) {
let mut cleaned: String = text
.chars()
.filter(|ch| !hidden(*ch))
.map(|ch| if breaks_line(ch) { ' ' } else { ch })
.collect();
cleaned = defang(&cleaned);
match cleaned.char_indices().nth(cap) {
Some((cut, _)) => {
cleaned.truncate(cut);
(cleaned, true)
}
None => (cleaned, false),
}
}
#[cfg(test)]
pub(crate) use bacnet_types::text::HIDDEN;
fn hidden(ch: char) -> bool {
bacnet_types::text::is_hidden(ch)
}
fn defang(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find("://") {
let (before, after) = rest.split_at(at);
let scheme_start = before
.char_indices()
.rev()
.find(|(_, ch)| !(ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')))
.map_or(0, |(i, ch)| i + ch.len_utf8());
let (lead, scheme) = before.split_at(scheme_start);
out.push_str(lead);
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => {
let x = if scheme.as_bytes()[1].is_ascii_uppercase() {
"XX"
} else {
"xx"
};
out.push_str(&scheme[..1]);
out.push_str(x);
out.push_str(&scheme[3..]);
out.push_str("://");
}
_ => {
out.push_str(scheme);
out.push_str("[:]//");
}
}
rest = &after[3..];
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn controls_bidi_and_urls_are_neutralised() {
let text = "Supply\u{0007} Fan\u{009B}\u{202E}gnp.exe\u{202C} see https://evil.example/x \
and ftp://h/f or HTTP://A";
let (cleaned, truncated) = clean(text, 256);
assert!(!truncated);
assert_eq!(
cleaned,
"Supply Fan gnp.exe see hxxps://evil.example/x and ftp[:]//h/f or HXXP://A"
);
assert!(!cleaned.chars().any(|ch| ch.is_control() || hidden(ch)));
}
#[test]
fn every_hidden_range_is_dropped() {
for &(low, high) in HIDDEN {
for ch in [low, high] {
let text = format!("a{ch}b");
assert_eq!(clean(&text, 256).0, "ab", "U+{:04X}", u32::from(ch));
}
}
for named in [
'\u{E0041}',
'\u{FE0F}',
'\u{E0100}',
'\u{115F}',
'\u{1160}',
'\u{3164}',
'\u{FFA0}',
] {
assert!(hidden(named), "U+{:04X}", u32::from(named));
}
assert_eq!(clean("a\u{2028}b\u{2029}c", 256).0, "a b c");
for kept in ['A', 'é', '\u{4E2D}', '\u{1F600}', '\u{FE10}', '\u{3165}'] {
assert!(!hidden(kept), "U+{:04X}", u32::from(kept));
}
}
#[test]
fn long_text_is_capped_by_characters() {
let text = "é".repeat(300);
let (cleaned, truncated) = clean(&text, 256);
assert!(truncated);
assert_eq!(cleaned.chars().count(), 256);
let value = envelope("ok", "device 1 name", 256);
assert_eq!(
value,
json!({ "untrusted": "ok", "source": "device 1 name", "truncated": false })
);
}
#[test]
fn a_bare_separator_is_left_alone() {
assert_eq!(defang("://x"), "[:]//x");
assert_eq!(defang("é://x éhttp://y"), "é[:]//x éhxxp://y");
assert_eq!(defang("no url here"), "no url here");
}
}