use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, LazyLock, Mutex, PoisonError};
use std::time::{Duration, Instant};
use tokio::sync::{watch, Semaphore};
use tracing::warn;
pub struct SanitizedHtml {
html: String,
}
impl SanitizedHtml {
pub fn clean(raw: &str) -> Self {
Self {
html: crate::feed::sanitize_html(raw),
}
}
pub async fn clean_off_runtime(raw: String) -> anyhow::Result<Self> {
tokio::task::spawn_blocking(move || Self::clean(&raw))
.await
.map_err(|e| anyhow::anyhow!("sanitizing an entry body failed: {e}"))
}
pub fn as_str(&self) -> &str {
&self.html
}
}
impl std::fmt::Display for SanitizedHtml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.html)
}
}
impl askama::filters::HtmlSafe for SanitizedHtml {}
pub enum BodyRender {
Html(SanitizedHtml),
TooLarge,
Unavailable,
TooSlow,
}
pub const MAX_RENDER_HTML_BYTES: usize = crate::feed::MAX_CONTENT_HTML_BYTES;
pub const RENDER_PERMITS: usize = 2;
pub const RENDER_WAIT: Duration = Duration::from_secs(2);
pub const CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
pub const CACHE_MAX_ENTRIES: usize = 256;
const SLOW_CLEAN: Duration = Duration::from_millis(500);
pub const SLOW_KEYS_MAX: usize = 4096;
fn thread_cpu_time() -> Option<Duration> {
#[cfg(unix)]
{
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
(rc == 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32))
}
#[cfg(not(unix))]
{
None
}
}
struct SlowSet {
keys: std::collections::HashSet<Key>,
order: std::collections::VecDeque<Key>,
}
impl SlowSet {
fn new() -> Self {
Self {
keys: std::collections::HashSet::new(),
order: std::collections::VecDeque::new(),
}
}
fn contains(&self, key: &Key) -> bool {
self.keys.contains(key)
}
fn insert(&mut self, key: Key) {
if !self.keys.insert(key) {
return;
}
self.order.push_back(key);
while self.order.len() > SLOW_KEYS_MAX {
if let Some(old) = self.order.pop_front() {
self.keys.remove(&old);
}
}
}
}
type Key = [u8; 32];
fn key_of(raw: &str) -> Key {
let digest = ring::digest::digest(&ring::digest::SHA256, raw.as_bytes());
let mut key = [0u8; 32];
key.copy_from_slice(digest.as_ref());
key
}
struct Cache {
max_bytes: usize,
max_entries: usize,
bytes: usize,
tick: u64,
slots: HashMap<Key, Slot>,
}
struct Slot {
html: Arc<str>,
last_used: u64,
}
impl Cache {
fn new(max_bytes: usize, max_entries: usize) -> Self {
Self {
max_bytes,
max_entries,
bytes: 0,
tick: 0,
slots: HashMap::new(),
}
}
fn get(&mut self, key: &Key) -> Option<Arc<str>> {
self.tick += 1;
let slot = self.slots.get_mut(key)?;
slot.last_used = self.tick;
Some(Arc::clone(&slot.html))
}
fn insert(&mut self, key: Key, html: Arc<str>) {
if html.len() > self.max_bytes || self.max_entries == 0 {
return;
}
if let Some(old) = self.slots.remove(&key) {
self.bytes -= old.html.len();
}
while !self.slots.is_empty()
&& (self.slots.len() >= self.max_entries || self.bytes + html.len() > self.max_bytes)
{
let Some(oldest) = self
.slots
.iter()
.min_by_key(|(_, s)| s.last_used)
.map(|(k, _)| *k)
else {
break;
};
if let Some(gone) = self.slots.remove(&oldest) {
self.bytes -= gone.html.len();
}
}
self.tick += 1;
self.bytes += html.len();
self.slots.insert(
key,
Slot {
html,
last_used: self.tick,
},
);
}
}
#[derive(Clone)]
enum Outcome {
Html(Arc<str>),
Unavailable,
}
type InFlight = HashMap<Key, watch::Receiver<Option<Outcome>>>;
struct Inner {
permits: Arc<Semaphore>,
#[cfg(test)]
permits_total: usize,
wait: Duration,
cache: Mutex<Cache>,
in_flight: Mutex<InFlight>,
slow_keys: Mutex<SlowSet>,
slow: Duration,
#[cfg(test)]
after_lookup: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
#[cfg(test)]
during_clean: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
cleans: AtomicUsize,
}
#[derive(Clone)]
pub struct BodyRenderer(Arc<Inner>);
struct InFlightGuard {
inner: Arc<Inner>,
key: Key,
}
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.inner.in_flight().remove(&self.key);
}
}
impl BodyRenderer {
pub fn new(permits: usize, wait: Duration, cache_bytes: usize, cache_entries: usize) -> Self {
Self(Arc::new(Inner {
permits: Arc::new(Semaphore::new(permits)),
#[cfg(test)]
permits_total: permits,
wait,
cache: Mutex::new(Cache::new(cache_bytes, cache_entries)),
in_flight: Mutex::new(HashMap::new()),
slow_keys: Mutex::new(SlowSet::new()),
slow: SLOW_CLEAN,
#[cfg(test)]
after_lookup: Mutex::new(None),
#[cfg(test)]
during_clean: Mutex::new(None),
cleans: AtomicUsize::new(0),
}))
}
#[cfg(test)]
fn with_slow_threshold(mut self, slow: Duration) -> Self {
Arc::get_mut(&mut self.0)
.expect("set the slow threshold before sharing the renderer")
.slow = slow;
self
}
#[cfg(test)]
fn set_after_lookup(&self, hook: impl Fn() + Send + Sync + 'static) {
*self
.0
.after_lookup
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(Box::new(hook));
}
#[cfg(test)]
fn set_during_clean(&self, hook: impl Fn() + Send + Sync + 'static) {
*self
.0
.during_clean
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(Box::new(hook));
}
pub fn shared() -> &'static BodyRenderer {
static SHARED: LazyLock<BodyRenderer> = LazyLock::new(|| {
BodyRenderer::new(
RENDER_PERMITS,
RENDER_WAIT,
CACHE_MAX_BYTES,
CACHE_MAX_ENTRIES,
)
});
&SHARED
}
pub async fn render(&self, raw: String) -> anyhow::Result<BodyRender> {
if raw.len() > MAX_RENDER_HTML_BYTES {
warn!(
bytes = raw.len(),
bound = MAX_RENDER_HTML_BYTES,
"stored entry body is over the ingest bound; not rendering it"
);
return Ok(BodyRender::TooLarge);
}
let inner = Arc::clone(&self.0);
let (raw, key, hit) = tokio::task::spawn_blocking(move || {
let key = key_of(&raw);
let hit = inner.cache().get(&key);
(raw, key, hit)
})
.await
.map_err(|e| anyhow::anyhow!("looking up an entry body failed: {e}"))?;
if let Some(html) = hit {
return Ok(BodyRender::Html(SanitizedHtml {
html: html.to_string(),
}));
}
#[cfg(test)]
if let Some(hook) = self
.0
.after_lookup
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
{
hook();
}
let mut rx = {
let mut in_flight = self.0.in_flight();
if let Some(html) = self.0.cache().get(&key) {
return Ok(BodyRender::Html(SanitizedHtml {
html: html.to_string(),
}));
}
match in_flight.get(&key) {
Some(rx) => rx.clone(),
None if self.0.slow_keys().contains(&key) => {
return Ok(BodyRender::TooSlow);
}
None => {
let (tx, rx) = watch::channel(None);
in_flight.insert(key, rx.clone());
tokio::spawn(Self::lead(Arc::clone(&self.0), key, raw, tx));
rx
}
}
};
let outcome = match rx.wait_for(|v| v.is_some()).await {
Ok(v) => v.clone(),
Err(_gone) => anyhow::bail!("sanitizing an entry body failed"),
};
match outcome {
Some(Outcome::Html(html)) => Ok(BodyRender::Html(SanitizedHtml {
html: html.to_string(),
})),
Some(Outcome::Unavailable) | None => Ok(BodyRender::Unavailable),
}
}
async fn lead(inner: Arc<Inner>, key: Key, raw: String, tx: watch::Sender<Option<Outcome>>) {
let _guard = InFlightGuard {
inner: Arc::clone(&inner),
key,
};
let permit = match tokio::time::timeout(
inner.wait,
Arc::clone(&inner.permits).acquire_owned(),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(_)) | Err(_) => {
warn!(
bytes = raw.len(),
waited_ms = inner.wait.as_millis() as u64,
"no sanitizer permit became free; not rendering this entry body now"
);
let _ = tx.send(Some(Outcome::Unavailable));
return;
}
};
let work = Arc::clone(&inner);
let cleaned = tokio::task::spawn_blocking(move || {
let _permit = permit;
work.cleans.fetch_add(1, Ordering::Relaxed);
let started = Instant::now();
let started_cpu = thread_cpu_time();
#[cfg(test)]
if let Some(hook) = work
.during_clean
.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
{
hook();
}
let html: Arc<str> = Arc::from(SanitizedHtml::clean(&raw).html.as_str());
let took = started.elapsed();
let worked = match (started_cpu, thread_cpu_time()) {
(Some(before), Some(after)) => after.saturating_sub(before),
_ => took,
};
if worked > work.slow {
work.slow_keys().insert(key);
}
if took > SLOW_CLEAN {
warn!(
bytes = raw.len(),
out_bytes = html.len(),
took_ms = took.as_millis() as u64,
cpu_ms = worked.as_millis() as u64,
sha256 = %hex_prefix(&key),
"a stored entry body was slow to sanitize (#226); cached now, so once per process"
);
}
work.cache().insert(key, Arc::clone(&html));
html
})
.await;
if let Ok(html) = cleaned {
let _ = tx.send(Some(Outcome::Html(html)));
}
}
pub fn cleans(&self) -> usize {
self.0.cleans.load(Ordering::Relaxed)
}
pub fn in_flight(&self) -> usize {
self.0.in_flight().len()
}
pub fn cache_size(&self) -> (usize, usize) {
let cache = self.0.cache();
(cache.slots.len(), cache.bytes)
}
#[cfg(test)]
fn permits(&self) -> Arc<Semaphore> {
Arc::clone(&self.0.permits)
}
}
impl Inner {
fn cache(&self) -> std::sync::MutexGuard<'_, Cache> {
self.cache.lock().unwrap_or_else(PoisonError::into_inner)
}
fn slow_keys(&self) -> std::sync::MutexGuard<'_, SlowSet> {
self.slow_keys
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
fn in_flight(&self) -> std::sync::MutexGuard<'_, InFlight> {
self.in_flight
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
}
fn hex_prefix(key: &Key) -> String {
key[..8].iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::feed::sanitize_html;
const HOSTILE: &[&str] = &[
"<p>a</p><script>alert(1)</script>",
r#"<img src="x" onerror="alert(1)">"#,
r#"<a href="javascript:alert(1)">x</a>"#,
r#"<a href=" JaVaScRiPt:alert(1)">x</a>"#,
r#"<iframe src="https://evil.example/"></iframe>"#,
r#"<p onclick="alert(1)" style="background:url(javascript:alert(1))">x</p>"#,
"<svg><script>alert(1)</script></svg>",
"<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>",
"<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\">",
r#"<form action="https://evil.example/"><input name="pw"></form>"#,
r#"<object data="javascript:alert(1)"></object><embed src="x.swf">"#,
r#"<meta http-equiv="refresh" content="0;url=javascript:alert(1)">"#,
"<base href=\"https://evil.example/\"><a href=\"/x\">x</a>",
r#"<p><img src=x onerror=alert(1)//><a href="javascript:alert(1)">x</a></p>"#,
r#"<div onclick="alert(1)"><a href=" javascript:alert(1)" onerror="x">y</a></div>"#,
];
#[test]
fn clean_is_the_ingest_sanitizer() {
for raw in HOSTILE.iter().chain(ARTICLES) {
assert_eq!(
SanitizedHtml::clean(raw).as_str(),
sanitize_html(raw),
"render-time clean diverged from ingest on {raw:?}",
);
}
}
#[test]
fn clean_leaves_nothing_active() {
for raw in HOSTILE {
let out = SanitizedHtml::clean(raw).as_str().to_ascii_lowercase();
for needle in [
"<script",
"onerror",
"onclick",
"javascript:",
"<iframe",
"<style",
"<form",
"<input",
"<object",
"<embed",
"<meta",
"<base",
"<svg",
"<math",
] {
assert!(!out.contains(needle), "`{needle}` survived {raw:?}: {out}");
}
}
}
const ARTICLES: &[&str] = &[
concat!(
"<h1>Title</h1><h2 id=x>Sub</h2>",
"<p>Hello world © 2026 — caf\u{e9} \u{1f600} \u{a0}nbsp-char ",
"<a href='https://example.com/a?b=1&c=2' title=\"t\" rel=nofollow target=_blank>link</a>",
" <strong>b</strong> <em>i</em> <code>x < y && z</code></p>",
"<!-- a comment --><br><hr/>",
"<figure><img src=\"https://example.com/i.png\" alt=\"pic\" width=\"10\" ",
"srcset=\"https://example.com/i2.png 2x\"><figcaption>cap</figcaption></figure>",
"<blockquote cite=\"https://example.com\"><p>quote</p></blockquote>",
"<pre><code class=\"language-rust\">fn main() { if a < b && c > d {} }</code></pre>",
"<ul><li>one<li>two</ul><ol start=3><li>three</ol>",
"<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table>",
"<div class=\"wrap\"><span style=\"color:red\">styled</span></div>",
"<p>unclosed <b>bold <i>both</p><font color=red>font</font>",
),
concat!(
"<h2>Lists, tables, ruby</h2>",
"<ul><li><p>para in item</p><ul><li>nested <a href=\"https://e.example/\">",
"<img src=\"https://e.example/i.png\" alt=\"\"></a></li></ul></li><li>two</li></ul>",
"<dl><dt>term</dt><dd>def <em>emph</em></dd><dt>t2</dt><dd>d2</dd></dl>",
"<table><caption>cap</caption><colgroup><col><col></colgroup>",
"<thead><tr><th>a</th><th>b</th></tr></thead>",
"<tbody><tr><td><p>cell & para</p></td><td><table><tr><td>inner</td></tr></table></td></tr>",
"</tbody></table>",
"<p>A<ruby>\u{6f22}<rp>(</rp><rt>kan</rt><rp>)</rp></ruby> line<br>break ",
"x<sup>2</sup> H<sub>2</sub>O <del>old</del><ins>new</ins> <abbr title=\"t\">ab</abbr> ",
"<q>quoted</q> <kbd>Ctrl</kbd> <mark>m</mark> <time>2026</time> <s>s</s> <u>u</u></p>",
"<hr><details><summary>more</summary><p>hidden</p></details>",
"<blockquote><p>q1</p><blockquote><p>q2</p></blockquote></blockquote>",
"<h3>code</h3><pre><code>a <b> && c\n indented</code></pre>",
"<div><div><span><a href=\"https://e.example/\"><b><i>deep</i></b></a></span></div></div>",
),
"<p>x</p>",
"plain text with a bare < and an & and a > and \"quotes\" and 'apostrophes'",
"",
];
#[test]
fn cleaning_stored_html_is_idempotent() {
for raw in ARTICLES.iter().chain(HOSTILE) {
let stored = sanitize_html(raw);
assert_eq!(
SanitizedHtml::clean(&stored).as_str(),
stored,
"re-cleaning the stored form of {raw:?} changed it",
);
}
}
#[test]
fn plain_text_summaries_differ_only_in_how_u00a0_is_spelled() {
let text = "a < b && c > d\n\"q\" 'a' caf\u{e9} \u{1f600}\nnon\u{a0}breaking";
let stored = crate::feed::plain_text_to_html(text);
let cleaned = SanitizedHtml::clean(&stored);
assert_eq!(cleaned.as_str(), stored.replace('\u{a0}', " "));
let without_nbsp = crate::feed::plain_text_to_html(&text.replace('\u{a0}', " "));
assert_eq!(SanitizedHtml::clean(&without_nbsp).as_str(), without_nbsp);
}
#[test]
fn a_stripped_tfoot_gains_a_tbody_and_nothing_else() {
let stored = sanitize_html(
"<table><tbody><tr><td>a</td></tr></tbody><tfoot><tr><td>f</td></tr></tfoot></table>",
);
assert_eq!(
stored,
"<table><tbody><tr><td>a</td></tr></tbody><tr><td>f</td></tr></table>"
);
let out = SanitizedHtml::clean(&stored);
assert_eq!(
out.as_str(),
"<table><tbody><tr><td>a</td></tr></tbody><tbody><tr><td>f</td></tr></tbody></table>"
);
assert_eq!(SanitizedHtml::clean(out.as_str()).as_str(), out.as_str());
}
#[tokio::test]
async fn bodies_ingest_stores_render_in_full() {
const REST: &str = "<p>REST-OF-ARTICLE</p>";
let mut listing = String::new();
let mut i = 0;
while listing.len() < 245_000 {
listing.push_str(&format!("<item id=\"{i}\">value {i}</item>\n"));
i += 1;
}
let shapes = [
(
"li in li after a stripped section",
format!("<ul><li>one<section><li>two</li></section></li></ul>{REST}"),
),
(
"p in p after a stripped form",
format!("<p>a<form><p>b</p></form></p>{REST}"),
),
(
"a in a after a stripped object",
format!(
r#"<a href="https://x.example/">a<object><a href="https://y.example/">b</a></object></a>{REST}"#
),
),
(
"heading in heading after a stripped form",
format!("<h1>a<form><h2>b</h2></form></h1>{REST}"),
),
(
"245 KB unhighlighted XML listing",
format!("<pre><code>{listing}</code></pre>{REST}"),
),
(
"rt in a span in ruby",
format!("<p>A<ruby><span>\u{6f22}<rt>kan</rt></span></ruby> B</p>{REST}"),
),
];
let r = renderer();
let mut cut = Vec::new();
for (name, raw) in &shapes {
let stored = sanitize_html(raw);
assert!(
stored.contains("REST-OF-ARTICLE"),
"{name}: ingest itself dropped the rest; this shape tests nothing"
);
let out = match r.render(stored.clone()).await.unwrap() {
BodyRender::Html(out) => out,
refused => {
cut.push(format!(
"{name} (refused whole: {})",
match refused {
BodyRender::TooLarge => "too large",
BodyRender::Unavailable => "unavailable",
BodyRender::TooSlow => "too slow",
BodyRender::Html(_) => unreachable!(),
}
));
continue;
}
};
if !out.as_str().contains("REST-OF-ARTICLE") {
cut.push(format!(
"{name} (stored tail …{:?})",
&stored[stored.len().saturating_sub(60)..]
));
continue;
}
assert_eq!(
out.as_str(),
sanitize_html(&stored),
"{name}: render diverged from the ingest sanitizer on the stored body"
);
}
assert!(
cut.is_empty(),
"the rest of the article was cut at render for: {cut:#?}"
);
for (name, raw) in &shapes[4..] {
let stored = sanitize_html(raw);
assert_eq!(
SanitizedHtml::clean(&stored).as_str(),
stored,
"{name}: re-cleaning changed the stored body"
);
}
}
#[tokio::test]
async fn cleaning_off_the_runtime_is_the_same_clean() {
for raw in HOSTILE.iter().chain(ARTICLES) {
let off = SanitizedHtml::clean_off_runtime(raw.to_string())
.await
.unwrap();
assert_eq!(off.as_str(), SanitizedHtml::clean(raw).as_str());
}
}
#[test]
fn the_reader_template_has_no_safe_filter() {
let template = include_str!("../templates/entry.html");
let squashed: String = template.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
!squashed.contains("|safe"),
"templates/entry.html uses `|safe` again"
);
}
fn renderer() -> BodyRenderer {
BodyRenderer::new(
RENDER_PERMITS,
Duration::from_millis(200),
CACHE_MAX_BYTES,
CACHE_MAX_ENTRIES,
)
}
fn html(render: BodyRender) -> String {
match render {
BodyRender::Html(h) => h.as_str().to_string(),
BodyRender::TooLarge => panic!("the body was refused as too large"),
BodyRender::Unavailable => panic!("the body was refused as unavailable"),
BodyRender::TooSlow => panic!("the body was refused as too slow"),
}
}
#[tokio::test]
async fn a_body_over_the_stored_bound_is_refused_without_cleaning() {
assert_eq!(MAX_RENDER_HTML_BYTES, crate::feed::MAX_CONTENT_HTML_BYTES);
let r = renderer();
let depth = MAX_RENDER_HTML_BYTES / 11 + 1;
let over = format!("{}{}", "<div>".repeat(depth), "</div>".repeat(depth));
assert!(over.len() > MAX_RENDER_HTML_BYTES);
let started = Instant::now();
let out = r.render(over).await.unwrap();
assert!(
matches!(out, BodyRender::TooLarge),
"an over-size body was not refused"
);
assert_eq!(r.cleans(), 0, "the sanitizer ran on an over-size body");
assert!(
started.elapsed() < Duration::from_secs(1),
"refusing an over-size body took {:?}",
started.elapsed()
);
assert_eq!(r.cache_size(), (0, 0), "a refused body was cached");
let at = format!("<p>{}</p>", "a".repeat(MAX_RENDER_HTML_BYTES - 7));
assert_eq!(at.len(), MAX_RENDER_HTML_BYTES);
let out = html(r.render(at.clone()).await.unwrap());
assert_eq!(
out, at,
"a body exactly at the bound was not rendered whole"
);
assert_eq!(r.cleans(), 1);
}
#[tokio::test]
async fn exhausted_permits_mean_a_note_not_a_blocked_worker() {
let r = BodyRenderer::new(
2,
Duration::from_millis(300),
CACHE_MAX_BYTES,
CACHE_MAX_ENTRIES,
);
let cached = "<p>already seen</p>".to_string();
assert_eq!(html(r.render(cached.clone()).await.unwrap()), cached);
assert_eq!(r.cleans(), 1);
let held = r.permits().acquire_many_owned(2).await.unwrap();
assert_eq!(r.permits().available_permits(), 0);
let fresh = "<p>never seen</p>".to_string();
let started = Instant::now();
let (render, timer_done) = tokio::join!(r.render(fresh.clone()), async {
tokio::time::sleep(Duration::from_millis(20)).await;
Instant::now()
});
let render_done = Instant::now();
assert!(
matches!(render.unwrap(), BodyRender::Unavailable),
"an uncached body rendered with no permit free"
);
assert_eq!(r.cleans(), 1, "the sanitizer ran with no permit free");
assert!(
render_done.duration_since(started) >= Duration::from_millis(300),
"the render did not wait for a permit"
);
assert!(
timer_done < render_done
&& timer_done.duration_since(started) < Duration::from_millis(250),
"a concurrent task was held up by the waiting render: timer at {:?}, render at {:?}",
timer_done.duration_since(started),
render_done.duration_since(started)
);
assert_eq!(html(r.render(cached.clone()).await.unwrap()), cached);
assert_eq!(r.cleans(), 1);
drop(held);
assert_eq!(html(r.render(fresh.clone()).await.unwrap()), fresh);
assert_eq!(r.cleans(), 2);
}
#[tokio::test]
async fn a_body_is_cleaned_once_and_then_served_from_the_cache() {
let r = renderer();
for raw in HOSTILE.iter().chain(ARTICLES) {
let first = html(r.render(raw.to_string()).await.unwrap());
let cleans = r.cleans();
let second = html(r.render(raw.to_string()).await.unwrap());
assert_eq!(
r.cleans(),
cleans,
"the second render of {raw:?} ran the sanitizer"
);
assert_eq!(first, second);
assert_eq!(first, sanitize_html(raw));
}
let distinct: std::collections::HashSet<_> = HOSTILE.iter().chain(ARTICLES).collect();
assert_eq!(r.cleans(), distinct.len());
}
#[tokio::test]
async fn bodies_that_differ_anywhere_do_not_share_a_cache_slot() {
let r = renderer();
let filler = "x".repeat(50_000);
let pairs = [
("<p>aaaa</p>".to_string(), "<p>bbbb</p>".to_string()),
(
format!("<p>{filler}A{filler}</p>"),
format!("<p>{filler}B{filler}</p>"),
),
(
format!("<p>{filler}</p><p>tail one</p>"),
format!("<p>{filler}</p><p>tail two</p>"),
),
(
format!("<p>head one</p><p>{filler}</p>"),
format!("<p>head two</p><p>{filler}</p>"),
),
(
r#"<a href="https://a.example/">x</a>"#.to_string(),
r#"<a href="https://b.example/">x</a>"#.to_string(),
),
];
for (a, b) in &pairs {
assert_eq!(a.len(), b.len(), "the pair must have the same length");
let out_a = html(r.render(a.clone()).await.unwrap());
let out_b = html(r.render(b.clone()).await.unwrap());
assert_eq!(out_a, sanitize_html(a));
assert_eq!(out_b, sanitize_html(b));
assert_ne!(
out_a, out_b,
"two different bodies rendered the same markup"
);
let cleans = r.cleans();
assert_eq!(html(r.render(a.clone()).await.unwrap()), out_a);
assert_eq!(html(r.render(b.clone()).await.unwrap()), out_b);
assert_eq!(r.cleans(), cleans);
}
}
#[tokio::test]
async fn the_cache_stays_within_its_byte_and_entry_bounds() {
let body = |i: usize| format!("<p>{i:04} {}</p>", "b".repeat(20_000));
let r = BodyRenderer::new(2, Duration::from_millis(200), 100_000, 1_000);
for i in 0..50 {
html(r.render(body(i)).await.unwrap());
let (entries, bytes) = r.cache_size();
assert!(bytes <= 100_000, "cache held {bytes} B after body {i}");
assert!(
(1..=4).contains(&entries),
"cache held {entries} entries after body {i}"
);
}
let cleans = r.cleans();
html(r.render(body(49)).await.unwrap());
assert_eq!(r.cleans(), cleans, "the most recent body was evicted");
html(r.render(body(0)).await.unwrap());
assert_eq!(r.cleans(), cleans + 1, "the oldest body was still cached");
let r = BodyRenderer::new(2, Duration::from_millis(200), 1_000_000, 3);
for i in 0..3 {
html(r.render(body(i)).await.unwrap());
}
html(r.render(body(0)).await.unwrap()); html(r.render(body(3)).await.unwrap()); let cleans = r.cleans();
html(r.render(body(0)).await.unwrap());
assert_eq!(r.cleans(), cleans, "a recently hit body was evicted");
html(r.render(body(1)).await.unwrap());
assert_eq!(
r.cleans(),
cleans + 1,
"the least recently used body was kept"
);
assert_eq!(r.cache_size().0, 3);
let r = renderer();
for i in 0..CACHE_MAX_ENTRIES + 20 {
html(r.render(body(i)).await.unwrap());
}
let (entries, bytes) = r.cache_size();
assert_eq!(entries, CACHE_MAX_ENTRIES);
assert!(bytes <= CACHE_MAX_BYTES);
let r = BodyRenderer::new(2, Duration::from_millis(200), 10_000, 10);
let big = body(0);
assert!(big.len() > 10_000);
assert_eq!(html(r.render(big.clone()).await.unwrap()), big);
assert_eq!(r.cache_size(), (0, 0));
html(r.render(big.clone()).await.unwrap());
assert_eq!(r.cleans(), 2);
}
fn slow_body() -> String {
let depth = 48 * 1024 / 11;
format!("{}{}", "<div>".repeat(depth), "</div>".repeat(depth))
}
#[tokio::test]
async fn concurrent_renders_of_one_body_clean_it_once() {
let r = BodyRenderer::new(
2,
Duration::from_secs(10),
CACHE_MAX_BYTES,
CACHE_MAX_ENTRIES,
);
let misses = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&misses);
r.set_after_lookup(move || {
counted.fetch_add(1, Ordering::SeqCst);
});
let slow = slow_body();
let spawn_render = |body: String| {
let r = r.clone();
tokio::spawn(async move { r.render(body).await })
};
let a = spawn_render(slow.clone());
let b = spawn_render(slow.clone());
let deadline = Instant::now() + Duration::from_secs(30);
while misses.load(Ordering::SeqCst) < 2 || r.cleans() < 1 {
assert!(Instant::now() < deadline, "the slow pair never started");
tokio::time::sleep(Duration::from_millis(1)).await;
}
assert_eq!(r.in_flight(), 1, "the slow pair is not one clean in flight");
assert_eq!(
r.permits().available_permits(),
1,
"the slow pair holds other than one permit: the leader took more, or the joiner took one"
);
let other = "<p>a different, cheap body</p>".to_string();
assert_eq!(html(r.render(other.clone()).await.unwrap()), other);
assert_eq!(
r.in_flight(),
1,
"the cheap body waited behind the slow pair: no permit was free"
);
let (a, b) = (
html(a.await.unwrap().unwrap()),
html(b.await.unwrap().unwrap()),
);
assert_eq!(a, b);
assert_eq!(a, sanitize_html(&slow));
assert_eq!(
r.cleans(),
2,
"the slow body was cleaned more than once, or the cheap one was not"
);
}
#[tokio::test]
async fn a_dropped_requester_leaves_no_stale_in_flight_entry() {
let r = BodyRenderer::new(
2,
Duration::from_secs(10),
CACHE_MAX_BYTES,
CACHE_MAX_ENTRIES,
);
let slow = slow_body();
tokio::select! {
_ = r.render(slow.clone()) => panic!("the slow body rendered within 5 ms"),
_ = tokio::time::sleep(Duration::from_millis(5)) => {}
}
assert_eq!(
r.in_flight(),
1,
"the dropped requester's clean is not in flight"
);
let again = html(r.render(slow.clone()).await.unwrap());
assert_eq!(again, sanitize_html(&slow));
assert_eq!(
r.cleans(),
1,
"the dropped requester's clean was not joined"
);
assert_eq!(r.in_flight(), 0, "a finished clean stayed in flight");
html(r.render("<p>x</p>".to_string()).await.unwrap());
html(r.render("<p>x</p>".to_string()).await.unwrap());
assert_eq!(r.in_flight(), 0);
}
#[cfg(unix)]
#[tokio::test]
async fn a_clean_stalled_by_a_busy_host_is_not_marked_slow() {
let r = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 1)
.with_slow_threshold(Duration::from_millis(100));
r.set_during_clean(|| std::thread::sleep(Duration::from_millis(200)));
let a = "<p>an ordinary article</p>".to_string();
let b = "<p>another one</p>".to_string();
html(r.render(a.clone()).await.unwrap());
html(r.render(b).await.unwrap());
let again = r.render(a).await.unwrap();
assert!(
matches!(again, BodyRender::Html(_)),
"a clean stalled 200 ms without working was marked slow"
);
assert_eq!(r.cleans(), 3);
}
#[tokio::test]
async fn a_slow_body_is_not_cleaned_again_after_eviction() {
let r = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 1)
.with_slow_threshold(Duration::ZERO);
let a = "<p>body A</p>".to_string();
let b = "<p>body B</p>".to_string();
html(r.render(a.clone()).await.unwrap());
html(r.render(b.clone()).await.unwrap());
assert_eq!(r.cleans(), 2);
let again = r.render(a.clone()).await.unwrap();
assert_eq!(r.cleans(), 2, "an evicted slow body was cleaned again");
assert!(
matches!(again, BodyRender::TooSlow),
"an evicted slow body was not reported as too slow"
);
let fast = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 1);
html(fast.render(a.clone()).await.unwrap());
html(fast.render(b.clone()).await.unwrap());
html(fast.render(a.clone()).await.unwrap());
assert_eq!(fast.cleans(), 3, "a fast body was refused after eviction");
}
#[tokio::test]
async fn a_clean_that_finishes_after_a_miss_is_not_repeated() {
let r = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 16);
let body = "<p>raced</p>".to_string();
let cached: Arc<str> = Arc::from(sanitize_html(&body).as_str());
let key = key_of(&body);
let inner = Arc::clone(&r.0);
r.set_after_lookup(move || inner.cache().insert(key, Arc::clone(&cached)));
let out = html(r.render(body.clone()).await.unwrap());
assert_eq!(out, sanitize_html(&body));
assert_eq!(
r.cleans(),
0,
"a clean that had just finished was run again"
);
}
#[test]
fn the_shared_renderer_has_the_documented_parameters() {
let shared = BodyRenderer::shared();
assert_eq!(shared.0.permits_total, RENDER_PERMITS);
assert_eq!(shared.0.wait, RENDER_WAIT);
assert_eq!(shared.0.slow, SLOW_CLEAN);
let cache = shared.0.cache();
assert_eq!(cache.max_bytes, CACHE_MAX_BYTES);
assert_eq!(cache.max_entries, CACHE_MAX_ENTRIES);
}
}