Skip to main content

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 `&nbsp;`, 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="java&#115;cript: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&nbsp;world &copy; 2026 &#8212; 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 &lt; y &amp;&amp; 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 &amp; 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 &lt;b&gt; &amp;&amp; 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    /// `&nbsp;`. 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}', "&nbsp;"));
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 `&lt;` — 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!("&lt;item id=\"{i}\"&gt;value {i}&lt;/item&gt;\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}