feather_reader/sanitized_html.rs
1//! The reader view's article body.
2//!
3//! **This is its own module because of the private field**, for the reason
4//! `safe_link.rs` gives: a private field is private to the MODULE, so a type
5//! declared in `web.rs` could be built beside its own constructor there and the
6//! guarantee would be a convention again.
7//!
8//! Two things live here. [`SanitizedHtml`] is the type: markup that has been
9//! through the ingest sanitizer in this process. [`BodyRenderer`] is the path a
10//! stored body takes to become one at render, with the three bounds that keep
11//! a pathological row from costing more than its own page view: a size cap, a
12//! concurrency limit and a cache.
13//!
14//! **Why bounds, and why these.** Real bodies re-clean cheaply: on 545 real
15//! bodies from 20 public feeds, p50 31 µs, p99 1.1 ms, max 3.5 ms (a 561 KB
16//! body). But the sanitizer is quadratic on shapes any feed can serve, and
17//! **ingest can store them** (#226, fixed separately): a stored 2 MiB body of
18//! nested `<div>`s takes ~37 s to re-clean, a 2 MiB `&` run 2.4 s, a U+00A0
19//! run 3.4 s. Such rows may already exist in databases upgraded from ≤ 0.4.6,
20//! and until #226's fix lands any feed can add one; after it, bodies that
21//! sanitize under its timeout can still be slow here. So nothing here assumes
22//! a stored body is fast. The bounds cap what one slow body can cost
23//! everyone else: it is cleaned at most once per process (cache + single
24//! flight), holding one of two permits, so a second such body can be in the
25//! sanitizer at the same time and no more; other readers' uncached bodies
26//! wait up to two seconds for a permit and then get a "temporarily
27//! unavailable" note instead of a hung page. The slow body's own readers pay
28//! its full cost, once.
29//!
30//! An earlier version of this module tried to *predict* the cost with a scan
31//! that re-implemented html5ever's tree-construction rules in front of
32//! html5ever; three review rounds each found a mismatch, and the last found
33//! the scan cutting real articles (`li` in `li` after a stripped `<section>`,
34//! a 245 KB `<pre>` XML listing, `<rt>` inside a `<span>` in `<ruby>`). The
35//! bounds here predict nothing.
36
37use std::collections::HashMap;
38use std::sync::atomic::{AtomicUsize, Ordering};
39use std::sync::{Arc, LazyLock, Mutex, PoisonError};
40use std::time::{Duration, Instant};
41
42use tokio::sync::{watch, Semaphore};
43use tracing::warn;
44
45/// Article-body HTML that has been through the sanitizer **in this process**
46/// — and so can be emitted into a page without escaping.
47///
48/// **Why it exists (#151).** `entries.content_html` reached `entry.html` as a
49/// raw `Option<String>` rendered with `|safe` — the one expression in the
50/// reader that bypassed Askama's escaper. Its safety was ingest's `ammonia`
51/// pass in `feed.rs`, on a different code path, holding only while every
52/// future writer to the column remembered to go through it. That is the same
53/// procedural guard `SafeLink` replaced for the entry's `href`s.
54///
55/// **The guarantee cannot ride through storage.** The column is SQLite `TEXT`,
56/// so a type set at ingest means nothing by the time a row is read back. It is
57/// re-established at render instead: the only constructor runs the ingest
58/// sanitizer, `feed::sanitize_html`, over whatever the row holds. A newtype
59/// that wrapped the stored string without cleaning it was rejected in the
60/// issue as a guarantee in name only.
61///
62/// - **One policy.** It calls ingest's function rather than holding its own
63/// `ammonia` builder, so ingest and render cannot drift; a test pins them
64/// byte for byte.
65/// - **No change for readers.** Sanitizer output is a fixed point of the
66/// sanitizer, so a body ingest stored comes back byte-identical (all 545
67/// real bodies measured). Two known exceptions, both the same page to a
68/// browser: a literal U+00A0 in a standard.site plain-text summary comes
69/// back as ` `, and a table whose `<tfoot>` the policy stripped gains
70/// the `<tbody>` a browser would build around those rows anyway.
71/// - **Bounded blast radius.** Not here: in [`BodyRenderer`], which is how the
72/// reader's handler gets one of these from a stored row.
73///
74/// There is no `From<String>`, no `Deref`, and no public field. A raw string
75/// does not become one — not by struct literal (E0451, private field):
76///
77/// ```compile_fail,E0451
78/// use feather_reader::sanitized_html::SanitizedHtml;
79/// let _ = SanitizedHtml { html: String::from("<script>alert(1)</script>") };
80/// ```
81///
82/// and not by conversion (E0277, no `From`):
83///
84/// ```compile_fail,E0277
85/// use feather_reader::sanitized_html::SanitizedHtml;
86/// let _: SanitizedHtml = String::from("<script>alert(1)</script>").into();
87/// ```
88///
89/// Only through the cleaning constructor:
90///
91/// ```
92/// use feather_reader::sanitized_html::SanitizedHtml;
93/// let html = SanitizedHtml::clean("<p>hi</p><script>alert(1)</script>");
94/// assert_eq!(html.as_str(), "<p>hi</p>");
95/// ```
96pub struct SanitizedHtml {
97 html: String,
98}
99
100impl SanitizedHtml {
101 /// Sanitize `raw` with the ingest policy. The only way to make one.
102 ///
103 /// Blocks for as long as the sanitizer runs, on the whole of `raw`: on an
104 /// async task use [`SanitizedHtml::clean_off_runtime`], and for a stored
105 /// body use [`BodyRenderer`], which also applies the bounds.
106 pub fn clean(raw: &str) -> Self {
107 Self {
108 html: crate::feed::sanitize_html(raw),
109 }
110 }
111
112 /// [`SanitizedHtml::clean`] on tokio's blocking pool, so a slow clean
113 /// stalls no other request sharing the async worker. This does not make
114 /// the clean cheaper or bound it; [`BodyRenderer`] does that.
115 pub async fn clean_off_runtime(raw: String) -> anyhow::Result<Self> {
116 tokio::task::spawn_blocking(move || Self::clean(&raw))
117 .await
118 .map_err(|e| anyhow::anyhow!("sanitizing an entry body failed: {e}"))
119 }
120
121 /// The cleaned markup.
122 pub fn as_str(&self) -> &str {
123 &self.html
124 }
125}
126
127impl std::fmt::Display for SanitizedHtml {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.write_str(&self.html)
130 }
131}
132
133impl askama::filters::HtmlSafe for SanitizedHtml {}
134
135/// What the reader gets back for a stored body: the markup, or one of two
136/// reasons it is not shown. Both reasons are notes in `entry.html` pointing
137/// at the original.
138pub enum BodyRender {
139 /// Cleaned and ready to emit.
140 Html(SanitizedHtml),
141 /// The stored body is longer than [`MAX_RENDER_HTML_BYTES`], which ingest
142 /// never writes. It was not given to the sanitizer at all.
143 TooLarge,
144 /// Every sanitizer permit stayed busy for [`RENDER_WAIT`] — only while
145 /// [`RENDER_PERMITS`] slow bodies are being cleaned at once, each for the
146 /// first time this process. The reader can retry; by then the slow ones
147 /// are cached.
148 Unavailable,
149 /// The body was cleaned once this process and was slow to clean
150 /// (`SLOW_CLEAN`, 500 ms); it has since left the cache, and render does not
151 /// clean it again — that would put a slow clean on every view of it.
152 TooSlow,
153}
154
155/// The longest stored body render will sanitize: ingest's own stored bound
156/// (`feed::MAX_CONTENT_HTML_BYTES`), so no body ingest wrote is ever refused.
157/// Only a row that skipped `feed.rs`, or one from before the bound existed
158/// (#224), can be longer, and such a row is not given to the sanitizer at all.
159pub const MAX_RENDER_HTML_BYTES: usize = crate::feed::MAX_CONTENT_HTML_BYTES;
160
161/// How many stored bodies may be in the sanitizer at once, process-wide.
162///
163/// Normal content never contends for these: a real clean takes microseconds
164/// to a few milliseconds. The limit exists for the pathological row (~37 s
165/// for 2 MiB of nested `<div>`s), which can occupy at most one permit per
166/// page view of it, and so at most this many blocking-pool threads and CPU
167/// cores in total. One would let a single slow row stall every other uncached
168/// view for its whole duration; two keeps one lane open past one slow row,
169/// which is the most this path should ever spend.
170pub const RENDER_PERMITS: usize = 2;
171
172/// How long a page view waits for a sanitizer permit before showing the
173/// "temporarily unavailable" note instead of the body.
174///
175/// Real cleans finish in ≤ 3.5 ms, so a queue of real work drains in well
176/// under this; a wait this long means every permit is held by a pathological
177/// body, and the reader is better served by the note and the link to the
178/// original than by a page that hangs for the rest of a 37 s clean. The wait
179/// itself is async (`Semaphore::acquire`) and never occupies a worker thread.
180pub const RENDER_WAIT: Duration = Duration::from_secs(2);
181
182/// The most cleaned markup the render cache holds, in bytes of output.
183///
184/// Typical bodies are small (p50 4.4 KB, p99 132 KB), so [`CACHE_MAX_ENTRIES`]
185/// usually binds first and this is a ceiling on memory for a reader of large
186/// articles: at least four bodies of the maximum stored size fit, and a body
187/// whose cleaned form alone exceeds it is simply not cached.
188pub const CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
189
190/// The most bodies the render cache holds. A reader paging through a list
191/// re-views far fewer than this before anything is evicted.
192pub const CACHE_MAX_ENTRIES: usize = 256;
193
194/// A clean slower than this is logged with the body's size and hash, so the
195/// row — a hostile feed's body (#226), or one written some other way — can be
196/// found.
197const SLOW_CLEAN: Duration = Duration::from_millis(500);
198
199/// How many slow bodies render remembers (`SlowSet`). 32 bytes each, so
200/// 128 KiB at most; far more slow bodies than any instance should ever hold.
201pub const SLOW_KEYS_MAX: usize = 4096;
202
203/// CPU time the calling thread has used, where the platform reports it.
204///
205/// What marks a body slow ([`SlowSet`]), rather than the clock: a clean
206/// stalled behind other work — a hostile body's clean on the other permit,
207/// ingest, a CPU quota — takes long by the clock but not in work done, and a
208/// clock reading would mark an ordinary article "too complex" for the rest
209/// of the process. `None` off unix; the caller then falls back to the clock.
210fn thread_cpu_time() -> Option<Duration> {
211 #[cfg(unix)]
212 {
213 let mut ts = libc::timespec {
214 tv_sec: 0,
215 tv_nsec: 0,
216 };
217 // SAFETY: `clock_gettime` writes one `timespec` through a valid,
218 // exclusively borrowed pointer and keeps nothing.
219 let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
220 (rc == 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32))
221 }
222 #[cfg(not(unix))]
223 {
224 None
225 }
226}
227
228/// The bodies whose clean used more than [`SLOW_CLEAN`] of CPU time
229/// ([`thread_cpu_time`]), by hash, oldest out first. **Separate from the output cache on purpose:** the cache evicts
230/// by bytes and age, so a handful of slow ~2 MiB bodies viewed in turn would
231/// push each other out and re-run a ~37 s clean on every view. A body in this
232/// set is cleaned at most once per process however the cache churns; after
233/// its result leaves the cache it renders as [`BodyRender::TooSlow`].
234struct SlowSet {
235 keys: std::collections::HashSet<Key>,
236 order: std::collections::VecDeque<Key>,
237}
238
239impl SlowSet {
240 fn new() -> Self {
241 Self {
242 keys: std::collections::HashSet::new(),
243 order: std::collections::VecDeque::new(),
244 }
245 }
246
247 fn contains(&self, key: &Key) -> bool {
248 self.keys.contains(key)
249 }
250
251 fn insert(&mut self, key: Key) {
252 if !self.keys.insert(key) {
253 return;
254 }
255 self.order.push_back(key);
256 while self.order.len() > SLOW_KEYS_MAX {
257 if let Some(old) = self.order.pop_front() {
258 self.keys.remove(&old);
259 }
260 }
261 }
262}
263
264/// SHA-256 of the stored body: the cache key.
265///
266/// **A strong hash, not a fast one.** A collision here would serve one
267/// entry's markup for another's, so a fast non-cryptographic hash (whose
268/// collisions a hostile feed can manufacture) is not an option. `ring` is
269/// already a direct dependency.
270type Key = [u8; 32];
271
272fn key_of(raw: &str) -> Key {
273 let digest = ring::digest::digest(&ring::digest::SHA256, raw.as_bytes());
274 let mut key = [0u8; 32];
275 key.copy_from_slice(digest.as_ref());
276 key
277}
278
279/// Cleaned output, keyed by the hash of the stored body, least recently
280/// used out first, bounded in entries and in bytes of output.
281///
282/// Small enough (≤ [`CACHE_MAX_ENTRIES`]) that eviction is a scan for the
283/// oldest, which costs less than hashing the key did.
284struct Cache {
285 max_bytes: usize,
286 max_entries: usize,
287 bytes: usize,
288 tick: u64,
289 slots: HashMap<Key, Slot>,
290}
291
292struct Slot {
293 html: Arc<str>,
294 last_used: u64,
295}
296
297impl Cache {
298 fn new(max_bytes: usize, max_entries: usize) -> Self {
299 Self {
300 max_bytes,
301 max_entries,
302 bytes: 0,
303 tick: 0,
304 slots: HashMap::new(),
305 }
306 }
307
308 fn get(&mut self, key: &Key) -> Option<Arc<str>> {
309 self.tick += 1;
310 let slot = self.slots.get_mut(key)?;
311 slot.last_used = self.tick;
312 Some(Arc::clone(&slot.html))
313 }
314
315 fn insert(&mut self, key: Key, html: Arc<str>) {
316 if html.len() > self.max_bytes || self.max_entries == 0 {
317 return;
318 }
319 if let Some(old) = self.slots.remove(&key) {
320 self.bytes -= old.html.len();
321 }
322 while !self.slots.is_empty()
323 && (self.slots.len() >= self.max_entries || self.bytes + html.len() > self.max_bytes)
324 {
325 let Some(oldest) = self
326 .slots
327 .iter()
328 .min_by_key(|(_, s)| s.last_used)
329 .map(|(k, _)| *k)
330 else {
331 break;
332 };
333 if let Some(gone) = self.slots.remove(&oldest) {
334 self.bytes -= gone.html.len();
335 }
336 }
337 self.tick += 1;
338 self.bytes += html.len();
339 self.slots.insert(
340 key,
341 Slot {
342 html,
343 last_used: self.tick,
344 },
345 );
346 }
347}
348
349/// What one in-flight clean produced, broadcast to everyone who asked for the
350/// same body while it ran.
351#[derive(Clone)]
352enum Outcome {
353 Html(Arc<str>),
354 Unavailable,
355}
356
357/// The in-flight cleans, by body hash: a receiver that resolves when the
358/// leader finishes. Joiners clone the receiver and wait; they take no permit.
359type InFlight = HashMap<Key, watch::Receiver<Option<Outcome>>>;
360
361struct Inner {
362 permits: Arc<Semaphore>,
363 /// Test-only: the size of the permit pool as configured, which
364 /// `available_permits` cannot show while another test holds one.
365 #[cfg(test)]
366 permits_total: usize,
367 wait: Duration,
368 cache: Mutex<Cache>,
369 in_flight: Mutex<InFlight>,
370 /// Bodies already cleaned slowly once this process ([`SlowSet`]).
371 slow_keys: Mutex<SlowSet>,
372 /// A clean slower than this marks the body slow ([`SLOW_CLEAN`]; tests
373 /// lower it).
374 slow: Duration,
375 /// Test-only: runs between the cache lookup and the in-flight check.
376 #[cfg(test)]
377 after_lookup: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
378 /// Test-only: runs inside the timed clean, on the blocking thread.
379 #[cfg(test)]
380 during_clean: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
381 /// How many times the sanitizer has run through this renderer. Tests use
382 /// it to show the cap, the cache and single flight keep it from running.
383 cleans: AtomicUsize,
384}
385
386/// The path from a stored `content_html` row to a [`BodyRender`], with the
387/// three bounds. One process-wide instance, [`BodyRenderer::shared`], serves
388/// the reader; tests build their own with smaller parameters.
389///
390/// For a body of `n` bytes a render costs, in order:
391/// 1. nothing but a length check if `n` > [`MAX_RENDER_HTML_BYTES`]
392/// ([`BodyRender::TooLarge`]);
393/// 2. a SHA-256 of the body and a cache lookup, on the blocking pool;
394/// 3. on a miss, **single flight**: if the same body is already being
395/// cleaned, this request waits for that result and takes no permit;
396/// otherwise it becomes the leader and spawns the clean as its own task,
397/// so a requester that disconnects neither cancels the clean nor leaves
398/// anyone waiting on a leader that is gone;
399/// 4. the leader waits up to [`RENDER_WAIT`] for one of [`RENDER_PERMITS`]
400/// permits ([`BodyRender::Unavailable`] for everyone waiting if none
401/// comes), then cleans on the blocking pool holding the permit, caches the
402/// result and publishes it.
403///
404/// So each distinct body is cleaned at most once at a time; a fast body again
405/// only after it leaves the cache; and a body that was slow to clean
406/// (`SLOW_CLEAN`, 500 ms) at most once per process — after its result leaves the
407/// cache it renders as [`BodyRender::TooSlow`] instead (`SlowSet`). A slow
408/// body therefore costs one clean, holding one permit, per process. The
409/// async worker is never blocked: the hash, the lookup and the clean run on
410/// the blocking pool, and every wait is an async one.
411///
412/// **What these bounds are for.** A stored body may be slow to re-clean:
413/// ingest can store one (#226; real bodies p50 31 µs, max 3.5 ms, but a
414/// hostile feed's 2 MiB of nested `<div>`s re-cleans in ~37 s, a `&` run in
415/// 2.4 s), and such rows may already exist in databases upgraded from
416/// ≤ 0.4.6. The bounds here cap what such a row can cost other readers: it is
417/// cleaned once per process, holding one of two permits, and everyone else's
418/// uncached body waits at most [`RENDER_WAIT`] before a note. They do not
419/// predict or reduce its own cost.
420#[derive(Clone)]
421pub struct BodyRenderer(Arc<Inner>);
422
423/// Removes an in-flight entry when the leader's task ends, however it ends —
424/// result published, `Unavailable`, or a panic in the sanitizer — so a key
425/// can never stay in flight with no one working on it.
426struct InFlightGuard {
427 inner: Arc<Inner>,
428 key: Key,
429}
430
431impl Drop for InFlightGuard {
432 fn drop(&mut self) {
433 self.inner.in_flight().remove(&self.key);
434 }
435}
436
437impl BodyRenderer {
438 /// A renderer with its own permits and cache. The reader uses
439 /// [`BodyRenderer::shared`]; this is for tests and for the shared
440 /// instance's construction.
441 pub fn new(permits: usize, wait: Duration, cache_bytes: usize, cache_entries: usize) -> Self {
442 Self(Arc::new(Inner {
443 permits: Arc::new(Semaphore::new(permits)),
444 #[cfg(test)]
445 permits_total: permits,
446 wait,
447 cache: Mutex::new(Cache::new(cache_bytes, cache_entries)),
448 in_flight: Mutex::new(HashMap::new()),
449 slow_keys: Mutex::new(SlowSet::new()),
450 slow: SLOW_CLEAN,
451 #[cfg(test)]
452 after_lookup: Mutex::new(None),
453 #[cfg(test)]
454 during_clean: Mutex::new(None),
455 cleans: AtomicUsize::new(0),
456 }))
457 }
458
459 /// This renderer, with a different slow-clean threshold. Tests only:
460 /// the inner state must not be shared yet.
461 #[cfg(test)]
462 fn with_slow_threshold(mut self, slow: Duration) -> Self {
463 Arc::get_mut(&mut self.0)
464 .expect("set the slow threshold before sharing the renderer")
465 .slow = slow;
466 self
467 }
468
469 /// Run `hook` between the cache lookup and the in-flight check.
470 #[cfg(test)]
471 fn set_after_lookup(&self, hook: impl Fn() + Send + Sync + 'static) {
472 *self
473 .0
474 .after_lookup
475 .lock()
476 .unwrap_or_else(PoisonError::into_inner) = Some(Box::new(hook));
477 }
478
479 #[cfg(test)]
480 fn set_during_clean(&self, hook: impl Fn() + Send + Sync + 'static) {
481 *self
482 .0
483 .during_clean
484 .lock()
485 .unwrap_or_else(PoisonError::into_inner) = Some(Box::new(hook));
486 }
487
488 /// The process-wide renderer, with [`RENDER_PERMITS`], [`RENDER_WAIT`],
489 /// [`CACHE_MAX_BYTES`] and [`CACHE_MAX_ENTRIES`].
490 pub fn shared() -> &'static BodyRenderer {
491 static SHARED: LazyLock<BodyRenderer> = LazyLock::new(|| {
492 BodyRenderer::new(
493 RENDER_PERMITS,
494 RENDER_WAIT,
495 CACHE_MAX_BYTES,
496 CACHE_MAX_ENTRIES,
497 )
498 });
499 &SHARED
500 }
501
502 /// A stored body, cleaned for this render — or the reason it is not.
503 ///
504 /// `Err` only if the blocking pool failed to run the task (a panic in the
505 /// sanitizer, or runtime shutdown); the two bounded outcomes are values.
506 pub async fn render(&self, raw: String) -> anyhow::Result<BodyRender> {
507 if raw.len() > MAX_RENDER_HTML_BYTES {
508 warn!(
509 bytes = raw.len(),
510 bound = MAX_RENDER_HTML_BYTES,
511 "stored entry body is over the ingest bound; not rendering it"
512 );
513 return Ok(BodyRender::TooLarge);
514 }
515
516 // Hash and look up off the runtime: SHA-256 of a 2 MiB body is
517 // milliseconds, which is more than an async worker should spend.
518 let inner = Arc::clone(&self.0);
519 let (raw, key, hit) = tokio::task::spawn_blocking(move || {
520 let key = key_of(&raw);
521 let hit = inner.cache().get(&key);
522 (raw, key, hit)
523 })
524 .await
525 .map_err(|e| anyhow::anyhow!("looking up an entry body failed: {e}"))?;
526 if let Some(html) = hit {
527 return Ok(BodyRender::Html(SanitizedHtml {
528 html: html.to_string(),
529 }));
530 }
531 #[cfg(test)]
532 if let Some(hook) = self
533 .0
534 .after_lookup
535 .lock()
536 .unwrap_or_else(PoisonError::into_inner)
537 .as_ref()
538 {
539 hook();
540 }
541
542 // Join the clean already in flight for this body, or lead one. The
543 // map is checked and the entry inserted under one lock, so two misses
544 // for the same key cannot both lead. Under that lock, in this order:
545 // 1. the cache again — a leader may have finished between the lookup
546 // above and here (it caches before it leaves the in-flight table),
547 // and starting a second clean of a body just cleaned is the cost
548 // single flight exists to avoid;
549 // 2. a clean in flight — join it. Checked before the slow set because
550 // a leader marks its body slow before it publishes, and its
551 // joiners should get that result, not a refusal;
552 // 3. the slow set — cleaned slowly once already, and out of the cache
553 // now: not again (`TooSlow`);
554 // 4. otherwise lead.
555 // Lock order is always in-flight, then cache or slow set; nothing
556 // takes them the other way round.
557 let mut rx = {
558 let mut in_flight = self.0.in_flight();
559 if let Some(html) = self.0.cache().get(&key) {
560 return Ok(BodyRender::Html(SanitizedHtml {
561 html: html.to_string(),
562 }));
563 }
564 match in_flight.get(&key) {
565 Some(rx) => rx.clone(),
566 None if self.0.slow_keys().contains(&key) => {
567 return Ok(BodyRender::TooSlow);
568 }
569 None => {
570 let (tx, rx) = watch::channel(None);
571 in_flight.insert(key, rx.clone());
572 // The leader's work is its own task: a requester that goes
573 // away (client disconnect) does not cancel it, and the
574 // guard inside removes the entry however the task ends.
575 tokio::spawn(Self::lead(Arc::clone(&self.0), key, raw, tx));
576 rx
577 }
578 }
579 };
580 let outcome = match rx.wait_for(|v| v.is_some()).await {
581 Ok(v) => v.clone(),
582 // The leader's task ended without publishing: the sanitizer
583 // panicked or the runtime is shutting down.
584 Err(_gone) => anyhow::bail!("sanitizing an entry body failed"),
585 };
586 match outcome {
587 Some(Outcome::Html(html)) => Ok(BodyRender::Html(SanitizedHtml {
588 html: html.to_string(),
589 })),
590 Some(Outcome::Unavailable) | None => Ok(BodyRender::Unavailable),
591 }
592 }
593
594 /// The leader of one in-flight clean: take a permit (or give up), clean on
595 /// the blocking pool, cache, publish to every joiner.
596 async fn lead(inner: Arc<Inner>, key: Key, raw: String, tx: watch::Sender<Option<Outcome>>) {
597 let _guard = InFlightGuard {
598 inner: Arc::clone(&inner),
599 key,
600 };
601 let permit = match tokio::time::timeout(
602 inner.wait,
603 Arc::clone(&inner.permits).acquire_owned(),
604 )
605 .await
606 {
607 Ok(Ok(permit)) => permit,
608 // The permits closed (never: they live in a static) or the
609 // wait elapsed.
610 Ok(Err(_)) | Err(_) => {
611 warn!(
612 bytes = raw.len(),
613 waited_ms = inner.wait.as_millis() as u64,
614 "no sanitizer permit became free; not rendering this entry body now"
615 );
616 let _ = tx.send(Some(Outcome::Unavailable));
617 return;
618 }
619 };
620 let work = Arc::clone(&inner);
621 let cleaned = tokio::task::spawn_blocking(move || {
622 // The permit is held for exactly as long as this closure runs.
623 let _permit = permit;
624 work.cleans.fetch_add(1, Ordering::Relaxed);
625 let started = Instant::now();
626 let started_cpu = thread_cpu_time();
627 #[cfg(test)]
628 if let Some(hook) = work
629 .during_clean
630 .lock()
631 .unwrap_or_else(PoisonError::into_inner)
632 .as_ref()
633 {
634 hook();
635 }
636 let html: Arc<str> = Arc::from(SanitizedHtml::clean(&raw).html.as_str());
637 let took = started.elapsed();
638 // Slow by the work done, not by the clock: a clean stalled by a
639 // busy host is not a slow body (see `thread_cpu_time`).
640 let worked = match (started_cpu, thread_cpu_time()) {
641 (Some(before), Some(after)) => after.saturating_sub(before),
642 _ => took,
643 };
644 if worked > work.slow {
645 work.slow_keys().insert(key);
646 }
647 if took > SLOW_CLEAN {
648 warn!(
649 bytes = raw.len(),
650 out_bytes = html.len(),
651 took_ms = took.as_millis() as u64,
652 cpu_ms = worked.as_millis() as u64,
653 sha256 = %hex_prefix(&key),
654 "a stored entry body was slow to sanitize (#226); cached now, so once per process"
655 );
656 }
657 work.cache().insert(key, Arc::clone(&html));
658 html
659 })
660 .await;
661 // A join error (the sanitizer panicked) publishes nothing: the sender
662 // drops with this task, and joiners see the channel close.
663 if let Ok(html) = cleaned {
664 let _ = tx.send(Some(Outcome::Html(html)));
665 }
666 }
667
668 /// How many times this renderer has run the sanitizer.
669 pub fn cleans(&self) -> usize {
670 self.0.cleans.load(Ordering::Relaxed)
671 }
672
673 /// How many bodies are being cleaned right now.
674 pub fn in_flight(&self) -> usize {
675 self.0.in_flight().len()
676 }
677
678 /// Bodies in the cache, and the bytes of cleaned markup they hold.
679 pub fn cache_size(&self) -> (usize, usize) {
680 let cache = self.0.cache();
681 (cache.slots.len(), cache.bytes)
682 }
683
684 /// The permit pool, so a test can hold permits and show what a render
685 /// does without one.
686 #[cfg(test)]
687 fn permits(&self) -> Arc<Semaphore> {
688 Arc::clone(&self.0.permits)
689 }
690}
691
692impl Inner {
693 fn cache(&self) -> std::sync::MutexGuard<'_, Cache> {
694 // Nothing here panics while holding the lock; recovering a poisoned
695 // lock rather than propagating is the right call for a cache.
696 self.cache.lock().unwrap_or_else(PoisonError::into_inner)
697 }
698
699 fn slow_keys(&self) -> std::sync::MutexGuard<'_, SlowSet> {
700 self.slow_keys
701 .lock()
702 .unwrap_or_else(PoisonError::into_inner)
703 }
704
705 fn in_flight(&self) -> std::sync::MutexGuard<'_, InFlight> {
706 self.in_flight
707 .lock()
708 .unwrap_or_else(PoisonError::into_inner)
709 }
710}
711
712/// The first eight bytes of a key as hex, enough to find a row by.
713fn hex_prefix(key: &Key) -> String {
714 key[..8].iter().map(|b| format!("{b:02x}")).collect()
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use crate::feed::sanitize_html;
721
722 /// Hostile bodies, each with something the sanitizer must remove.
723 const HOSTILE: &[&str] = &[
724 "<p>a</p><script>alert(1)</script>",
725 r#"<img src="x" onerror="alert(1)">"#,
726 r#"<a href="javascript:alert(1)">x</a>"#,
727 r#"<a href=" JaVaScRiPt:alert(1)">x</a>"#,
728 r#"<iframe src="https://evil.example/"></iframe>"#,
729 r#"<p onclick="alert(1)" style="background:url(javascript:alert(1))">x</p>"#,
730 "<svg><script>alert(1)</script></svg>",
731 "<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>",
732 "<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\">",
733 r#"<form action="https://evil.example/"><input name="pw"></form>"#,
734 r#"<object data="javascript:alert(1)"></object><embed src="x.swf">"#,
735 r#"<meta http-equiv="refresh" content="0;url=javascript:alert(1)">"#,
736 "<base href=\"https://evil.example/\"><a href=\"/x\">x</a>",
737 r#"<p><img src=x onerror=alert(1)//><a href="javascript:alert(1)">x</a></p>"#,
738 r#"<div onclick="alert(1)"><a href=" javascript:alert(1)" onerror="x">y</a></div>"#,
739 ];
740
741 /// **Same policy as ingest, byte for byte.** The render-time clean is not a
742 /// second, independently maintained allow-list: if it were, the two would
743 /// drift, and a body ingest would refuse could render, or the reverse.
744 #[test]
745 fn clean_is_the_ingest_sanitizer() {
746 for raw in HOSTILE.iter().chain(ARTICLES) {
747 assert_eq!(
748 SanitizedHtml::clean(raw).as_str(),
749 sanitize_html(raw),
750 "render-time clean diverged from ingest on {raw:?}",
751 );
752 }
753 }
754
755 #[test]
756 fn clean_leaves_nothing_active() {
757 for raw in HOSTILE {
758 let out = SanitizedHtml::clean(raw).as_str().to_ascii_lowercase();
759 for needle in [
760 "<script",
761 "onerror",
762 "onclick",
763 "javascript:",
764 "<iframe",
765 "<style",
766 "<form",
767 "<input",
768 "<object",
769 "<embed",
770 "<meta",
771 "<base",
772 "<svg",
773 "<math",
774 ] {
775 assert!(!out.contains(needle), "`{needle}` survived {raw:?}: {out}");
776 }
777 }
778 }
779
780 /// Representative article markup, as a feed would send it — exercising what
781 /// ammonia normalizes: attribute quoting and order, `rel` on links, entities
782 /// (named, numeric, and the U+00A0 it re-encodes), void elements, comments,
783 /// stripped tags with kept text, and code with `<` and `&`.
784 const ARTICLES: &[&str] = &[
785 concat!(
786 "<h1>Title</h1><h2 id=x>Sub</h2>",
787 "<p>Hello world © 2026 — caf\u{e9} \u{1f600} \u{a0}nbsp-char ",
788 "<a href='https://example.com/a?b=1&c=2' title=\"t\" rel=nofollow target=_blank>link</a>",
789 " <strong>b</strong> <em>i</em> <code>x < y && z</code></p>",
790 "<!-- a comment --><br><hr/>",
791 "<figure><img src=\"https://example.com/i.png\" alt=\"pic\" width=\"10\" ",
792 "srcset=\"https://example.com/i2.png 2x\"><figcaption>cap</figcaption></figure>",
793 "<blockquote cite=\"https://example.com\"><p>quote</p></blockquote>",
794 "<pre><code class=\"language-rust\">fn main() { if a < b && c > d {} }</code></pre>",
795 "<ul><li>one<li>two</ul><ol start=3><li>three</ol>",
796 "<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table>",
797 "<div class=\"wrap\"><span style=\"color:red\">styled</span></div>",
798 "<p>unclosed <b>bold <i>both</p><font color=red>font</font>",
799 ),
800 concat!(
801 "<h2>Lists, tables, ruby</h2>",
802 "<ul><li><p>para in item</p><ul><li>nested <a href=\"https://e.example/\">",
803 "<img src=\"https://e.example/i.png\" alt=\"\"></a></li></ul></li><li>two</li></ul>",
804 "<dl><dt>term</dt><dd>def <em>emph</em></dd><dt>t2</dt><dd>d2</dd></dl>",
805 "<table><caption>cap</caption><colgroup><col><col></colgroup>",
806 "<thead><tr><th>a</th><th>b</th></tr></thead>",
807 "<tbody><tr><td><p>cell & para</p></td><td><table><tr><td>inner</td></tr></table></td></tr>",
808 "</tbody></table>",
809 "<p>A<ruby>\u{6f22}<rp>(</rp><rt>kan</rt><rp>)</rp></ruby> line<br>break ",
810 "x<sup>2</sup> H<sub>2</sub>O <del>old</del><ins>new</ins> <abbr title=\"t\">ab</abbr> ",
811 "<q>quoted</q> <kbd>Ctrl</kbd> <mark>m</mark> <time>2026</time> <s>s</s> <u>u</u></p>",
812 "<hr><details><summary>more</summary><p>hidden</p></details>",
813 "<blockquote><p>q1</p><blockquote><p>q2</p></blockquote></blockquote>",
814 "<h3>code</h3><pre><code>a <b> && c\n indented</code></pre>",
815 "<div><div><span><a href=\"https://e.example/\"><b><i>deep</i></b></a></span></div></div>",
816 ),
817 "<p>x</p>",
818 "plain text with a bare < and an & and a > and \"quotes\" and 'apostrophes'",
819 "",
820 ];
821
822 /// **Re-cleaning a stored body changes nothing.** What ingest stored is
823 /// already `sanitize_html` output, and that output is a fixed point — so a
824 /// reader sees exactly what they saw before the render-time clean existed.
825 #[test]
826 fn cleaning_stored_html_is_idempotent() {
827 for raw in ARTICLES.iter().chain(HOSTILE) {
828 let stored = sanitize_html(raw);
829 assert_eq!(
830 SanitizedHtml::clean(&stored).as_str(),
831 stored,
832 "re-cleaning the stored form of {raw:?} changed it",
833 );
834 }
835 }
836
837 /// **The one exception, pinned.** standard.site summaries are stored by
838 /// `plain_text_to_html`, which escapes `& < >` and adds `<br>` but leaves a
839 /// literal U+00A0 alone; the sanitizer's serializer writes U+00A0 as
840 /// ` `. That is the same character to a browser, so a reader sees no
841 /// change — but it is not byte-identical, and this says so. Everything
842 /// else in a plain-text summary survives byte for byte.
843 #[test]
844 fn plain_text_summaries_differ_only_in_how_u00a0_is_spelled() {
845 let text = "a < b && c > d\n\"q\" 'a' caf\u{e9} \u{1f600}\nnon\u{a0}breaking";
846 let stored = crate::feed::plain_text_to_html(text);
847 let cleaned = SanitizedHtml::clean(&stored);
848 assert_eq!(cleaned.as_str(), stored.replace('\u{a0}', " "));
849 let without_nbsp = crate::feed::plain_text_to_html(&text.replace('\u{a0}', " "));
850 assert_eq!(SanitizedHtml::clean(&without_nbsp).as_str(), without_nbsp);
851 }
852
853 /// **The second exception, found by a fixture.** The policy keeps `tr` but
854 /// not `tfoot`, so a feed table with a footer is stored with its footer
855 /// rows directly in `<table>`; parsing that again wraps them in a
856 /// `<tbody>` — which is what a browser builds from the stored form too, so
857 /// the page is the same. A second re-clean is a fixed point. (None of the
858 /// 545 real bodies measured has a `<tfoot>`.)
859 #[test]
860 fn a_stripped_tfoot_gains_a_tbody_and_nothing_else() {
861 let stored = sanitize_html(
862 "<table><tbody><tr><td>a</td></tr></tbody><tfoot><tr><td>f</td></tr></tfoot></table>",
863 );
864 assert_eq!(
865 stored,
866 "<table><tbody><tr><td>a</td></tr></tbody><tr><td>f</td></tr></table>"
867 );
868 let out = SanitizedHtml::clean(&stored);
869 assert_eq!(
870 out.as_str(),
871 "<table><tbody><tr><td>a</td></tr></tbody><tbody><tr><td>f</td></tr></tbody></table>"
872 );
873 assert_eq!(SanitizedHtml::clean(out.as_str()).as_str(), out.as_str());
874 }
875
876 /// **Review of #273, third round: shapes ingest legitimately stores must
877 /// render in full.** Each `raw` here is ordinary feed markup; `stored` is
878 /// exactly what ingest writes for it (`sanitize_html`), and the article
879 /// continues after the shape. The render-time clean must carry the whole
880 /// stored body through, with nothing cut and nothing noted:
881 ///
882 /// 1. ammonia strips a wrapper (`section`, `form`, `object`) that html5ever
883 /// had treated as a nesting boundary, so the stored form has `li` in
884 /// `li`, `p` in `p`, `a` in `a`, `h2` in `h1` with every closer matching;
885 /// 2. an unhighlighted `<pre><code>` XML listing of ~245 KB — every `<`
886 /// stored as `<` — followed by more article;
887 /// 3. `<rt>` inside an inline wrapper inside `<ruby>`.
888 ///
889 /// Red against the pre-scan this module used to have: five of the six
890 /// were cut before the sanitizer saw them.
891 ///
892 /// Each stored body goes through [`BodyRenderer::render`], the path the
893 /// reader takes, not just [`SanitizedHtml::clean`]: a render-time bound
894 /// tighter than what ingest stores (a size cap of 200 KB would refuse
895 /// the 245 KB listing) cuts the article just as surely as a pre-scan, and
896 /// a test of `clean` alone cannot see it (vacuous-test hunt of #273).
897 #[tokio::test]
898 async fn bodies_ingest_stores_render_in_full() {
899 const REST: &str = "<p>REST-OF-ARTICLE</p>";
900 let mut listing = String::new();
901 let mut i = 0;
902 while listing.len() < 245_000 {
903 listing.push_str(&format!("<item id=\"{i}\">value {i}</item>\n"));
904 i += 1;
905 }
906 let shapes = [
907 (
908 "li in li after a stripped section",
909 format!("<ul><li>one<section><li>two</li></section></li></ul>{REST}"),
910 ),
911 (
912 "p in p after a stripped form",
913 format!("<p>a<form><p>b</p></form></p>{REST}"),
914 ),
915 (
916 "a in a after a stripped object",
917 format!(
918 r#"<a href="https://x.example/">a<object><a href="https://y.example/">b</a></object></a>{REST}"#
919 ),
920 ),
921 (
922 "heading in heading after a stripped form",
923 format!("<h1>a<form><h2>b</h2></form></h1>{REST}"),
924 ),
925 (
926 "245 KB unhighlighted XML listing",
927 format!("<pre><code>{listing}</code></pre>{REST}"),
928 ),
929 (
930 "rt in a span in ruby",
931 format!("<p>A<ruby><span>\u{6f22}<rt>kan</rt></span></ruby> B</p>{REST}"),
932 ),
933 ];
934 let r = renderer();
935 let mut cut = Vec::new();
936 for (name, raw) in &shapes {
937 let stored = sanitize_html(raw);
938 assert!(
939 stored.contains("REST-OF-ARTICLE"),
940 "{name}: ingest itself dropped the rest; this shape tests nothing"
941 );
942 let out = match r.render(stored.clone()).await.unwrap() {
943 BodyRender::Html(out) => out,
944 refused => {
945 cut.push(format!(
946 "{name} (refused whole: {})",
947 match refused {
948 BodyRender::TooLarge => "too large",
949 BodyRender::Unavailable => "unavailable",
950 BodyRender::TooSlow => "too slow",
951 BodyRender::Html(_) => unreachable!(),
952 }
953 ));
954 continue;
955 }
956 };
957 if !out.as_str().contains("REST-OF-ARTICLE") {
958 cut.push(format!(
959 "{name} (stored tail …{:?})",
960 &stored[stored.len().saturating_sub(60)..]
961 ));
962 continue;
963 }
964 assert_eq!(
965 out.as_str(),
966 sanitize_html(&stored),
967 "{name}: render diverged from the ingest sanitizer on the stored body"
968 );
969 }
970 assert!(
971 cut.is_empty(),
972 "the rest of the article was cut at render for: {cut:#?}"
973 );
974 // The shapes without a stripped wrapper are fixed points: byte-identical.
975 for (name, raw) in &shapes[4..] {
976 let stored = sanitize_html(raw);
977 assert_eq!(
978 SanitizedHtml::clean(&stored).as_str(),
979 stored,
980 "{name}: re-cleaning changed the stored body"
981 );
982 }
983 }
984
985 /// The off-runtime constructor is the same clean, not a different one.
986 #[tokio::test]
987 async fn cleaning_off_the_runtime_is_the_same_clean() {
988 for raw in HOSTILE.iter().chain(ARTICLES) {
989 let off = SanitizedHtml::clean_off_runtime(raw.to_string())
990 .await
991 .unwrap();
992 assert_eq!(off.as_str(), SanitizedHtml::clean(raw).as_str());
993 }
994 }
995
996 /// **`entry.html` has no `|safe` left at all.** The body renders through
997 /// [`SanitizedHtml`]'s `HtmlSafe` impl; a `|safe` here would be the old
998 /// bypass, and on a raw `String` it would still compile.
999 #[test]
1000 fn the_reader_template_has_no_safe_filter() {
1001 let template = include_str!("../templates/entry.html");
1002 let squashed: String = template.chars().filter(|c| !c.is_whitespace()).collect();
1003 assert!(
1004 !squashed.contains("|safe"),
1005 "templates/entry.html uses `|safe` again"
1006 );
1007 }
1008
1009 // ----- the three bounds ------------------------------------------------
1010
1011 fn renderer() -> BodyRenderer {
1012 BodyRenderer::new(
1013 RENDER_PERMITS,
1014 Duration::from_millis(200),
1015 CACHE_MAX_BYTES,
1016 CACHE_MAX_ENTRIES,
1017 )
1018 }
1019
1020 fn html(render: BodyRender) -> String {
1021 match render {
1022 BodyRender::Html(h) => h.as_str().to_string(),
1023 BodyRender::TooLarge => panic!("the body was refused as too large"),
1024 BodyRender::Unavailable => panic!("the body was refused as unavailable"),
1025 BodyRender::TooSlow => panic!("the body was refused as too slow"),
1026 }
1027 }
1028
1029 /// **The size cap.** A body over [`MAX_RENDER_HTML_BYTES`] — which ingest
1030 /// never writes — is refused without the sanitizer running. The body here
1031 /// is the measured worst shape (nested `<div>`s, ~37 s at 2 MiB in
1032 /// release, far longer in debug), so a render that reached the sanitizer
1033 /// would also blow the time bound. A body exactly at the cap is cleaned.
1034 #[tokio::test]
1035 async fn a_body_over_the_stored_bound_is_refused_without_cleaning() {
1036 assert_eq!(MAX_RENDER_HTML_BYTES, crate::feed::MAX_CONTENT_HTML_BYTES);
1037 let r = renderer();
1038 let depth = MAX_RENDER_HTML_BYTES / 11 + 1;
1039 let over = format!("{}{}", "<div>".repeat(depth), "</div>".repeat(depth));
1040 assert!(over.len() > MAX_RENDER_HTML_BYTES);
1041 let started = Instant::now();
1042 let out = r.render(over).await.unwrap();
1043 assert!(
1044 matches!(out, BodyRender::TooLarge),
1045 "an over-size body was not refused"
1046 );
1047 assert_eq!(r.cleans(), 0, "the sanitizer ran on an over-size body");
1048 assert!(
1049 started.elapsed() < Duration::from_secs(1),
1050 "refusing an over-size body took {:?}",
1051 started.elapsed()
1052 );
1053 assert_eq!(r.cache_size(), (0, 0), "a refused body was cached");
1054
1055 let at = format!("<p>{}</p>", "a".repeat(MAX_RENDER_HTML_BYTES - 7));
1056 assert_eq!(at.len(), MAX_RENDER_HTML_BYTES);
1057 let out = html(r.render(at.clone()).await.unwrap());
1058 assert_eq!(
1059 out, at,
1060 "a body exactly at the bound was not rendered whole"
1061 );
1062 assert_eq!(r.cleans(), 1);
1063 }
1064
1065 /// **The concurrency limit, and that waiting for it never blocks the
1066 /// runtime.** With every permit held, an uncached body waits the renderer's
1067 /// wait and comes back [`BodyRender::Unavailable`] with the sanitizer never
1068 /// run; a cached body still renders at once; and a cheap task joined
1069 /// beside the waiting render completes long before it — on a
1070 /// current-thread runtime, which is where a blocking wait would show.
1071 /// When the permits come back, the same body renders.
1072 #[tokio::test]
1073 async fn exhausted_permits_mean_a_note_not_a_blocked_worker() {
1074 let r = BodyRenderer::new(
1075 2,
1076 Duration::from_millis(300),
1077 CACHE_MAX_BYTES,
1078 CACHE_MAX_ENTRIES,
1079 );
1080 let cached = "<p>already seen</p>".to_string();
1081 assert_eq!(html(r.render(cached.clone()).await.unwrap()), cached);
1082 assert_eq!(r.cleans(), 1);
1083
1084 let held = r.permits().acquire_many_owned(2).await.unwrap();
1085 assert_eq!(r.permits().available_permits(), 0);
1086
1087 let fresh = "<p>never seen</p>".to_string();
1088 let started = Instant::now();
1089 let (render, timer_done) = tokio::join!(r.render(fresh.clone()), async {
1090 tokio::time::sleep(Duration::from_millis(20)).await;
1091 Instant::now()
1092 });
1093 let render_done = Instant::now();
1094 assert!(
1095 matches!(render.unwrap(), BodyRender::Unavailable),
1096 "an uncached body rendered with no permit free"
1097 );
1098 assert_eq!(r.cleans(), 1, "the sanitizer ran with no permit free");
1099 assert!(
1100 render_done.duration_since(started) >= Duration::from_millis(300),
1101 "the render did not wait for a permit"
1102 );
1103 assert!(
1104 timer_done < render_done
1105 && timer_done.duration_since(started) < Duration::from_millis(250),
1106 "a concurrent task was held up by the waiting render: timer at {:?}, render at {:?}",
1107 timer_done.duration_since(started),
1108 render_done.duration_since(started)
1109 );
1110 // A cached body does not need a permit.
1111 assert_eq!(html(r.render(cached.clone()).await.unwrap()), cached);
1112 assert_eq!(r.cleans(), 1);
1113
1114 drop(held);
1115 assert_eq!(html(r.render(fresh.clone()).await.unwrap()), fresh);
1116 assert_eq!(r.cleans(), 2);
1117 }
1118
1119 /// **The cache.** A body is cleaned once per process: the second render of
1120 /// the same bytes does not run the sanitizer and returns the same markup.
1121 #[tokio::test]
1122 async fn a_body_is_cleaned_once_and_then_served_from_the_cache() {
1123 let r = renderer();
1124 for raw in HOSTILE.iter().chain(ARTICLES) {
1125 let first = html(r.render(raw.to_string()).await.unwrap());
1126 let cleans = r.cleans();
1127 let second = html(r.render(raw.to_string()).await.unwrap());
1128 assert_eq!(
1129 r.cleans(),
1130 cleans,
1131 "the second render of {raw:?} ran the sanitizer"
1132 );
1133 assert_eq!(first, second);
1134 assert_eq!(first, sanitize_html(raw));
1135 }
1136 // Distinct bodies were each cleaned exactly once.
1137 let distinct: std::collections::HashSet<_> = HOSTILE.iter().chain(ARTICLES).collect();
1138 assert_eq!(r.cleans(), distinct.len());
1139 }
1140
1141 /// **The key is the content, all of it.** Two bodies of the same length,
1142 /// or differing only far from the start or the end, or in a single byte,
1143 /// must each get their own markup — a collision would show one entry's
1144 /// body on another's page. (Entry ids are not part of the key: the same
1145 /// bytes clean to the same markup whichever row holds them.)
1146 #[tokio::test]
1147 async fn bodies_that_differ_anywhere_do_not_share_a_cache_slot() {
1148 let r = renderer();
1149 let filler = "x".repeat(50_000);
1150 let pairs = [
1151 ("<p>aaaa</p>".to_string(), "<p>bbbb</p>".to_string()),
1152 (
1153 format!("<p>{filler}A{filler}</p>"),
1154 format!("<p>{filler}B{filler}</p>"),
1155 ),
1156 (
1157 format!("<p>{filler}</p><p>tail one</p>"),
1158 format!("<p>{filler}</p><p>tail two</p>"),
1159 ),
1160 (
1161 format!("<p>head one</p><p>{filler}</p>"),
1162 format!("<p>head two</p><p>{filler}</p>"),
1163 ),
1164 (
1165 r#"<a href="https://a.example/">x</a>"#.to_string(),
1166 r#"<a href="https://b.example/">x</a>"#.to_string(),
1167 ),
1168 ];
1169 for (a, b) in &pairs {
1170 assert_eq!(a.len(), b.len(), "the pair must have the same length");
1171 let out_a = html(r.render(a.clone()).await.unwrap());
1172 let out_b = html(r.render(b.clone()).await.unwrap());
1173 assert_eq!(out_a, sanitize_html(a));
1174 assert_eq!(out_b, sanitize_html(b));
1175 assert_ne!(
1176 out_a, out_b,
1177 "two different bodies rendered the same markup"
1178 );
1179 // And again, from the cache this time.
1180 let cleans = r.cleans();
1181 assert_eq!(html(r.render(a.clone()).await.unwrap()), out_a);
1182 assert_eq!(html(r.render(b.clone()).await.unwrap()), out_b);
1183 assert_eq!(r.cleans(), cleans);
1184 }
1185 }
1186
1187 /// **The cache stays within its bounds**, in bytes and in entries, evicting
1188 /// the least recently used first; a body whose cleaned form alone is over
1189 /// the byte bound is rendered but not cached.
1190 #[tokio::test]
1191 async fn the_cache_stays_within_its_byte_and_entry_bounds() {
1192 let body = |i: usize| format!("<p>{i:04} {}</p>", "b".repeat(20_000));
1193 // Byte-bound first: 100 KB holds four 20 KB bodies.
1194 let r = BodyRenderer::new(2, Duration::from_millis(200), 100_000, 1_000);
1195 for i in 0..50 {
1196 html(r.render(body(i)).await.unwrap());
1197 let (entries, bytes) = r.cache_size();
1198 assert!(bytes <= 100_000, "cache held {bytes} B after body {i}");
1199 assert!(
1200 (1..=4).contains(&entries),
1201 "cache held {entries} entries after body {i}"
1202 );
1203 }
1204 // The most recent bodies are the ones kept; the first is long gone.
1205 let cleans = r.cleans();
1206 html(r.render(body(49)).await.unwrap());
1207 assert_eq!(r.cleans(), cleans, "the most recent body was evicted");
1208 html(r.render(body(0)).await.unwrap());
1209 assert_eq!(r.cleans(), cleans + 1, "the oldest body was still cached");
1210
1211 // A hit refreshes recency: 3 slots, touch the oldest, then insert.
1212 let r = BodyRenderer::new(2, Duration::from_millis(200), 1_000_000, 3);
1213 for i in 0..3 {
1214 html(r.render(body(i)).await.unwrap());
1215 }
1216 html(r.render(body(0)).await.unwrap()); // 0 is now the most recent
1217 html(r.render(body(3)).await.unwrap()); // evicts 1
1218 let cleans = r.cleans();
1219 html(r.render(body(0)).await.unwrap());
1220 assert_eq!(r.cleans(), cleans, "a recently hit body was evicted");
1221 html(r.render(body(1)).await.unwrap());
1222 assert_eq!(
1223 r.cleans(),
1224 cleans + 1,
1225 "the least recently used body was kept"
1226 );
1227 assert_eq!(r.cache_size().0, 3);
1228
1229 // Entry-bound: 256 entries, 50 KB bodies, 8 MiB — entries bind.
1230 let r = renderer();
1231 for i in 0..CACHE_MAX_ENTRIES + 20 {
1232 html(r.render(body(i)).await.unwrap());
1233 }
1234 let (entries, bytes) = r.cache_size();
1235 assert_eq!(entries, CACHE_MAX_ENTRIES);
1236 assert!(bytes <= CACHE_MAX_BYTES);
1237
1238 // Over the byte bound on its own: rendered, not cached, cleaned again.
1239 let r = BodyRenderer::new(2, Duration::from_millis(200), 10_000, 10);
1240 let big = body(0);
1241 assert!(big.len() > 10_000);
1242 assert_eq!(html(r.render(big.clone()).await.unwrap()), big);
1243 assert_eq!(r.cache_size(), (0, 0));
1244 html(r.render(big.clone()).await.unwrap());
1245 assert_eq!(r.cleans(), 2);
1246 }
1247
1248 /// A body slow enough to clean (~2 s in a debug build, ~20 ms in release)
1249 /// that two renders of it overlap: 48 KiB of nested `<div>`s.
1250 fn slow_body() -> String {
1251 let depth = 48 * 1024 / 11;
1252 format!("{}{}", "<div>".repeat(depth), "</div>".repeat(depth))
1253 }
1254
1255 /// **Single flight.** Two concurrent renders of the same uncached body run
1256 /// the sanitizer once and both get its result; the second does not take a
1257 /// permit. So while the pair is in flight exactly one of the two permits
1258 /// is taken, and a third, different body renders while the pair is still
1259 /// being cleaned.
1260 ///
1261 /// Checked by state, not by timestamps: the old form compared an instant
1262 /// taken inside the join with one taken after it, which always held, so a
1263 /// leader taking both permits or a joiner holding one while it waited
1264 /// passed (vacuous-test hunt of #273). The after-lookup hook counts the
1265 /// two misses; on this current-thread runtime the test only runs again
1266 /// once each render has yielded past the in-flight check, so by then the
1267 /// joiner has joined and anything it acquired without waiting is held.
1268 #[tokio::test]
1269 async fn concurrent_renders_of_one_body_clean_it_once() {
1270 let r = BodyRenderer::new(
1271 2,
1272 Duration::from_secs(10),
1273 CACHE_MAX_BYTES,
1274 CACHE_MAX_ENTRIES,
1275 );
1276 let misses = Arc::new(AtomicUsize::new(0));
1277 let counted = Arc::clone(&misses);
1278 r.set_after_lookup(move || {
1279 counted.fetch_add(1, Ordering::SeqCst);
1280 });
1281 let slow = slow_body();
1282 let spawn_render = |body: String| {
1283 let r = r.clone();
1284 tokio::spawn(async move { r.render(body).await })
1285 };
1286 let a = spawn_render(slow.clone());
1287 let b = spawn_render(slow.clone());
1288 // Both have missed the cache and passed the in-flight check, and the
1289 // leader holds its permit and is cleaning.
1290 let deadline = Instant::now() + Duration::from_secs(30);
1291 while misses.load(Ordering::SeqCst) < 2 || r.cleans() < 1 {
1292 assert!(Instant::now() < deadline, "the slow pair never started");
1293 tokio::time::sleep(Duration::from_millis(1)).await;
1294 }
1295 assert_eq!(r.in_flight(), 1, "the slow pair is not one clean in flight");
1296 assert_eq!(
1297 r.permits().available_permits(),
1298 1,
1299 "the slow pair holds other than one permit: the leader took more, or the joiner took one"
1300 );
1301
1302 let other = "<p>a different, cheap body</p>".to_string();
1303 assert_eq!(html(r.render(other.clone()).await.unwrap()), other);
1304 assert_eq!(
1305 r.in_flight(),
1306 1,
1307 "the cheap body waited behind the slow pair: no permit was free"
1308 );
1309
1310 let (a, b) = (
1311 html(a.await.unwrap().unwrap()),
1312 html(b.await.unwrap().unwrap()),
1313 );
1314 assert_eq!(a, b);
1315 assert_eq!(a, sanitize_html(&slow));
1316 assert_eq!(
1317 r.cleans(),
1318 2,
1319 "the slow body was cleaned more than once, or the cheap one was not"
1320 );
1321 }
1322
1323 /// **A requester that goes away leaves nothing behind.** The first render
1324 /// of a slow body is dropped a few milliseconds in (a client disconnect);
1325 /// the clean it led goes on as its own task. A second render of the same
1326 /// body joins that clean rather than starting another — one clean in all —
1327 /// and when it is done the in-flight table is empty again.
1328 #[tokio::test]
1329 async fn a_dropped_requester_leaves_no_stale_in_flight_entry() {
1330 let r = BodyRenderer::new(
1331 2,
1332 Duration::from_secs(10),
1333 CACHE_MAX_BYTES,
1334 CACHE_MAX_ENTRIES,
1335 );
1336 let slow = slow_body();
1337 tokio::select! {
1338 _ = r.render(slow.clone()) => panic!("the slow body rendered within 5 ms"),
1339 _ = tokio::time::sleep(Duration::from_millis(5)) => {}
1340 }
1341 assert_eq!(
1342 r.in_flight(),
1343 1,
1344 "the dropped requester's clean is not in flight"
1345 );
1346 let again = html(r.render(slow.clone()).await.unwrap());
1347 assert_eq!(again, sanitize_html(&slow));
1348 assert_eq!(
1349 r.cleans(),
1350 1,
1351 "the dropped requester's clean was not joined"
1352 );
1353 assert_eq!(r.in_flight(), 0, "a finished clean stayed in flight");
1354 // Nothing in flight after a plain render either, cached or not.
1355 html(r.render("<p>x</p>".to_string()).await.unwrap());
1356 html(r.render("<p>x</p>".to_string()).await.unwrap());
1357 assert_eq!(r.in_flight(), 0);
1358 }
1359
1360 /// **A slow body is cleaned once per process, eviction or not.** Every
1361 /// clean here counts as slow (threshold zero) and the cache holds one
1362 /// body, so viewing A, then B, evicts A. Viewing A again must not clean it
1363 /// a second time — a hostile feed's handful of slow bodies, viewed in
1364 /// turn, would otherwise re-run a ~37 s clean on every view and keep both
1365 /// permits busy. It is shown as too slow to display instead.
1366 /// **A busy host does not make a body slow** (review of #273). A clean
1367 /// stalled by other work — a hostile body's clean on the other permit,
1368 /// ingest, a CPU quota — took long by the clock but not in work done, and
1369 /// marking it slow would show an ordinary article as "too complex" for
1370 /// the rest of the process. The slow set counts the clean's own CPU
1371 /// time: here a clean that waits 200 ms without working is not marked,
1372 /// and is cleaned again after eviction like any fast body.
1373 #[cfg(unix)]
1374 #[tokio::test]
1375 async fn a_clean_stalled_by_a_busy_host_is_not_marked_slow() {
1376 let r = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 1)
1377 .with_slow_threshold(Duration::from_millis(100));
1378 r.set_during_clean(|| std::thread::sleep(Duration::from_millis(200)));
1379 let a = "<p>an ordinary article</p>".to_string();
1380 let b = "<p>another one</p>".to_string();
1381 html(r.render(a.clone()).await.unwrap());
1382 html(r.render(b).await.unwrap());
1383 let again = r.render(a).await.unwrap();
1384 assert!(
1385 matches!(again, BodyRender::Html(_)),
1386 "a clean stalled 200 ms without working was marked slow"
1387 );
1388 assert_eq!(r.cleans(), 3);
1389 }
1390
1391 #[tokio::test]
1392 async fn a_slow_body_is_not_cleaned_again_after_eviction() {
1393 let r = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 1)
1394 .with_slow_threshold(Duration::ZERO);
1395 let a = "<p>body A</p>".to_string();
1396 let b = "<p>body B</p>".to_string();
1397 html(r.render(a.clone()).await.unwrap());
1398 html(r.render(b.clone()).await.unwrap());
1399 assert_eq!(r.cleans(), 2);
1400 let again = r.render(a.clone()).await.unwrap();
1401 assert_eq!(r.cleans(), 2, "an evicted slow body was cleaned again");
1402 assert!(
1403 matches!(again, BodyRender::TooSlow),
1404 "an evicted slow body was not reported as too slow"
1405 );
1406 // A body that was fast stays an ordinary cache miss: cleaned again.
1407 let fast = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 1);
1408 html(fast.render(a.clone()).await.unwrap());
1409 html(fast.render(b.clone()).await.unwrap());
1410 html(fast.render(a.clone()).await.unwrap());
1411 assert_eq!(fast.cleans(), 3, "a fast body was refused after eviction");
1412 }
1413
1414 /// **No second clean in the window between a miss and the in-flight
1415 /// check.** A request misses the cache; before it takes the in-flight
1416 /// lock, the leader of the same body finishes, caches its result and
1417 /// leaves the in-flight table. The request must use that result, not
1418 /// lead a second clean.
1419 #[tokio::test]
1420 async fn a_clean_that_finishes_after_a_miss_is_not_repeated() {
1421 let r = BodyRenderer::new(2, Duration::from_secs(10), 1_000_000, 16);
1422 let body = "<p>raced</p>".to_string();
1423 let cached: Arc<str> = Arc::from(sanitize_html(&body).as_str());
1424 let key = key_of(&body);
1425 let inner = Arc::clone(&r.0);
1426 r.set_after_lookup(move || inner.cache().insert(key, Arc::clone(&cached)));
1427 let out = html(r.render(body.clone()).await.unwrap());
1428 assert_eq!(out, sanitize_html(&body));
1429 assert_eq!(
1430 r.cleans(),
1431 0,
1432 "a clean that had just finished was run again"
1433 );
1434 }
1435
1436 /// The shared instance carries the documented parameters. The permit
1437 /// count is the configured pool, not `available_permits`: the web tests
1438 /// render through this same process-wide instance concurrently, so a
1439 /// permit held by one of them made this flaky; and the slow threshold is
1440 /// [`SLOW_CLEAN`], which nothing checked on the shared instance
1441 /// (vacuous-test hunt of #273).
1442 #[test]
1443 fn the_shared_renderer_has_the_documented_parameters() {
1444 let shared = BodyRenderer::shared();
1445 assert_eq!(shared.0.permits_total, RENDER_PERMITS);
1446 assert_eq!(shared.0.wait, RENDER_WAIT);
1447 assert_eq!(shared.0.slow, SLOW_CLEAN);
1448 let cache = shared.0.cache();
1449 assert_eq!(cache.max_bytes, CACHE_MAX_BYTES);
1450 assert_eq!(cache.max_entries, CACHE_MAX_ENTRIES);
1451 }
1452}