Skip to main content

plates_render/
visibility.rs

1//! Audience visibility filtering: which *parts* of a document leave.
2//!
3//! The gate ([`prov::exports`]) decides which documents a site publishes. This
4//! decides which regions of one does, filtered against the same audience name,
5//! so a body and the site holding it can never disagree about who a paragraph
6//! is for.
7//!
8//! # A region is a container
9//!
10//! Every grammar spells a marked region as one node, and twig parses all three
11//! into the same AST kind — a `container` with a name, a class list and
12//! children:
13//!
14//! | Grammar | Spelling | `name` | classes |
15//! |---|---|---|---|
16//! | Markdown | `:::vis{.family}` … `:::` | `"vis"` | `family` |
17//! | Markdown (inline) | `:vis[text]{.family}` | `"vis"` | `family` |
18//! | Djot | `{.vis .family}` on the line above `:::` | `""` | `vis family` |
19//! | HTML | `<div class="vis family">` | `"div"` | `vis family` |
20//!
21//! So the predicate is uniform and needs no per-grammar branch: **a region is a
22//! container named `vis`, or one whose classes contain `vis`**, and **its
23//! declared audiences are its classes, less `vis` itself**.
24//! That is why a Djot body and a Markdown body filter through one function
25//! rather than two that agree until one of them is fixed.
26//!
27//! # Why the parser and not a scanner
28//!
29//! This module used to scan text for `:::vis{…}` and `:vis[…]{…}` without
30//! parsing it, which was grammar-blind on purpose — one scanner for three
31//! grammars. It was also blind to everything else, and the cost was a
32//! disclosure bug: a marker inside a code span
33//! (`` `:::vis{.family}` ``, in a document *explaining* the syntax) was treated
34//! as a real directive, and a real directive whose fence a list had indented
35//! was not.
36//!
37//! twig parses the body it is going to render anyway, so the spans are
38//! available for free and they are the spans the renderer will agree with.
39//! prov re-exports twig for exactly this ([`prov::twig`]).
40//!
41//! # Fail-closed
42//!
43//! Filtering is a disclosure boundary, so every way this can fail ends with
44//! *less* leaving rather than more. A body whose grammar cannot be parsed, a
45//! region this cannot account for, a marker left standing after the walk — all
46//! are [`Error`], never a body returned unfiltered. See [`Error`] for why the
47//! residue check exists at all.
48
49use prov::ContentFormat;
50use prov::twig::{self, Editor, MarkdownExtensions};
51
52/// The class — and, in Markdown, the directive name — that marks a region as
53/// audience-scoped.
54///
55/// One word across all three grammars, because it is the *marker*, not a
56/// grammar's spelling of one.
57pub const MARKER: &str = "vis";
58
59/// Why a body could not be filtered.
60///
61/// Every variant means the caller must **not** publish the body it passed in.
62/// There is no "filtered as best we could" outcome on purpose: a partial filter
63/// is indistinguishable from a complete one by inspection, and the thing it
64/// silently keeps is the thing someone declared private.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Error {
67    /// The body could not be parsed, so no region in it could be located.
68    Parse(String),
69    /// An edit twig refused. Structural, not authorial — a bug here rather than
70    /// in the document.
71    Edit(String),
72    /// A marker survived the filter.
73    ///
74    /// The backstop for the failure this module is most likely to have: a
75    /// region spelled in a way this module does not recognize is a region
76    /// nothing removed, and its content is then published to everyone. Cheap to
77    /// check, and it converts the worst outcome (a silent leak) into the
78    /// ordinary one (a refused publish naming the document).
79    Residue {
80        /// What was still there, for the message.
81        found: String,
82    },
83    /// A region the parser could not read as one.
84    ///
85    /// The signature of twig's one structural gap here: it does not nest
86    /// *inline* directives, so the outer half of `:vis[a :vis[b]{.x} c]{.y}`
87    /// parses as a bare `:vis` carrying neither attributes nor an interior,
88    /// while `a`, `c` and the `{.y}` that scoped them stay outside it as prose.
89    ///
90    /// Unwrapping that marker would delete the word `:vis` and publish
91    /// everything it was scoping — a leak with no marker left behind for
92    /// [`Residue`](Self::Residue) to find, which is why it is caught here by
93    /// shape instead. Nested *block* regions are unaffected and work.
94    Malformed {
95        /// The source of the region that could not be read.
96        found: String,
97    },
98}
99
100impl std::fmt::Display for Error {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::Parse(e) => write!(f, "body could not be parsed for visibility filtering: {e}"),
104            Self::Edit(e) => write!(f, "visibility filter could not edit the body: {e}"),
105            Self::Malformed { found } => write!(
106                f,
107                "a `{MARKER}` region could not be read as one ({found}) — an inline region \
108                 nested inside another inline region is not supported; use a block region \
109                 (`:::{MARKER}`) for the outer one"
110            ),
111            Self::Residue { found } => write!(
112                f,
113                "a `{MARKER}` marker survived visibility filtering ({found}) — refusing to \
114                 publish a body whose audience regions were not all resolved"
115            ),
116        }
117    }
118}
119
120impl std::error::Error for Error {}
121
122/// Who the filtered body is for.
123#[derive(Debug, Clone, Copy)]
124pub enum Audience<'a> {
125    /// Every region survives; only the markers are removed.
126    ///
127    /// For rendering a document *as its author sees it* — an editor preview,
128    /// a local build with no audience chosen. Never for a publish.
129    All,
130    /// A region survives when it declares at least one of these.
131    ///
132    /// An empty slice keeps nothing, which is the honest reading of "for no
133    /// audience" and matches the gate: a document declaring nothing is visible
134    /// to no one.
135    Only(&'a [&'a str]),
136}
137
138impl Audience<'_> {
139    /// Whether a region declaring `declared` survives.
140    fn admits(&self, declared: &[String]) -> bool {
141        match self {
142            Self::All => true,
143            // Case-insensitive and trimmed, which is what this has always been.
144            // Deliberately *not* tightened to the gate's exact match in the same
145            // change that moves the spelling to classes: two ways for a region
146            // to vanish silently, landing together, is one migration nobody can
147            // debug. `SitePlan::case_drift` is where that argument belongs.
148            Self::Only(wanted) => declared.iter().any(|d| {
149                let d = d.trim();
150                wanted.iter().any(|w| d.eq_ignore_ascii_case(w.trim()))
151            }),
152        }
153    }
154}
155
156/// Cheap pre-check: is it even worth parsing this body?
157///
158/// Text-level and deliberately over-eager — it answers "might there be a
159/// region", and a false positive costs one parse. A false *negative* would skip
160/// the filter on a body that needed it, so this must stay wider than the real
161/// syntax, never narrower.
162pub fn has_visibility_directives(body: &str) -> bool {
163    body.contains(MARKER)
164}
165
166/// A region twig found, reduced to what filtering needs.
167struct Region {
168    /// The node's whole byte range.
169    span: std::ops::Range<usize>,
170    /// Its interior, when it has one — what survives an unwrap.
171    content: Option<std::ops::Range<usize>>,
172    /// The audiences it declares: its classes, less [`MARKER`].
173    declared: Vec<String>,
174}
175
176/// Read a container as a visibility region, or `None` when it is some other
177/// container (an HTML `<figure>`, a Markdown `:::note`).
178///
179/// The whole per-grammar difference lives here, and it amounts to two ways of
180/// carrying one marker: Markdown puts it in the directive's *name*, Djot and
181/// HTML in the element's *class list*. Everything downstream sees one shape.
182fn region_of(node: &twig::FlatNode) -> Option<Region> {
183    if !matches!(node.kind, twig::Kind::Container) {
184        return None;
185    }
186    let classes: Vec<String> = node
187        .attrs
188        .iter()
189        .find(|(k, _)| k == "class")
190        .and_then(|(_, v)| v.as_deref())
191        .map(|v| v.split_whitespace().map(str::to_string).collect())
192        .unwrap_or_default();
193
194    let named = node.name.as_deref() == Some(MARKER);
195    let classed = classes.iter().any(|c| c == MARKER);
196    if !named && !classed {
197        return None;
198    }
199
200    Some(Region {
201        span: node.span.clone(),
202        content: node.content_span.clone(),
203        declared: classes.into_iter().filter(|c| c != MARKER).collect(),
204    })
205}
206
207/// Filter `body` to the regions `audience` may see.
208///
209/// The document's own grammar decides how a region is spelled and twig decides
210/// where it is; this decides which survive. Returns the body with every region
211/// either removed or unwrapped — never one with a marker still in it (see
212/// [`Error::Residue`]).
213pub fn filter_body(
214    body: &str,
215    format: ContentFormat,
216    audience: Audience<'_>,
217) -> Result<String, Error> {
218    if !has_visibility_directives(body) {
219        return Ok(body.to_string());
220    }
221
222    let mut editor = Editor::new_ext(body.as_bytes(), twig_format(format), extensions(format))
223        .map_err(|e| Error::Parse(format!("{e:?}")))?;
224
225    // One region per pass, re-reading the tree each time: twig reparses after
226    // every edit, so every span but the one just used is stale. The alternative
227    // — collecting all the spans and splicing them back-to-front — is wrong the
228    // moment regions nest, because an outer region's end moves when its inner
229    // one is rewritten.
230    //
231    // Bounded by the region count: each pass removes exactly one container,
232    // either by deleting it or by replacing it with its own interior, and
233    // neither puts a `vis` container back.
234    loop {
235        let nodes = editor.nodes().map_err(|e| Error::Parse(format!("{e:?}")))?;
236        let Some(region) = nodes.iter().find_map(region_of) else {
237            break;
238        };
239
240        // A region with neither an interior nor a declared audience is not a
241        // region twig read correctly — see `Error::Malformed`. Refusing here is
242        // what stops the unwrap below from stripping the marker and publishing
243        // the text it was scoping.
244        if region.content.is_none() && region.declared.is_empty() {
245            let source = editor
246                .source_str()
247                .map_err(|e| Error::Edit(format!("{e:?}")))?;
248            return Err(Error::Malformed {
249                found: source
250                    .get(region.span.clone())
251                    .unwrap_or("?")
252                    .replace('\n', "\\n"),
253            });
254        }
255
256        let replacement = if audience.admits(&region.declared) {
257            // Unwrap: the region's own text stays, its marker goes. Taken from
258            // the *current* source rather than the original, which is a
259            // different string after the first pass.
260            match &region.content {
261                Some(interior) => {
262                    let source = editor
263                        .source_str()
264                        .map_err(|e| Error::Edit(format!("{e:?}")))?;
265                    source
266                        .get(interior.clone())
267                        .ok_or_else(|| {
268                            Error::Edit(format!("interior {interior:?} is not a char boundary"))
269                        })?
270                        .to_string()
271                }
272                // A container with no interior has nothing to keep.
273                None => String::new(),
274            }
275        } else {
276            String::new()
277        };
278
279        let start = attrs_aware_start(
280            &editor
281                .source_str()
282                .map_err(|e| Error::Edit(format!("{e:?}")))?,
283            region.span.start,
284        );
285        editor
286            .edit_range(start, region.span.end, &replacement)
287            .map_err(|e| Error::Edit(format!("{e:?}")))?;
288    }
289
290    let out = editor
291        .source_str()
292        .map_err(|e| Error::Edit(format!("{e:?}")))?;
293    residue_check(&out, format)?;
294    Ok(out)
295}
296
297/// Extend a region's start backwards over a standalone attribute line.
298///
299/// Djot writes a block's attributes on the line *above* it (`{.vis .family}`
300/// then `:::`), and twig attaches them to the container while leaving them
301/// outside its `span`. Deleting the span alone would leave the attribute line
302/// behind as a paragraph — which publishes the *audience's name* to everyone,
303/// a small disclosure in its own right and a visible artefact either way.
304///
305/// Only a line that is nothing but a `{…}` block is absorbed, so prose ending
306/// in a brace is untouched.
307fn attrs_aware_start(source: &str, span_start: usize) -> usize {
308    let before = &source[..span_start];
309    // Only a block region has a line above it. An inline one starts mid-line,
310    // where the preceding text is prose and absorbing it would eat the sentence.
311    let Some(trimmed) = before.strip_suffix('\n') else {
312        return span_start;
313    };
314    let line_start = trimmed.rfind('\n').map(|i| i + 1).unwrap_or(0);
315    let line = trimmed[line_start..].trim();
316    if line.starts_with('{') && line.ends_with('}') && line.len() > 1 {
317        line_start
318    } else {
319        span_start
320    }
321}
322
323/// Refuse a body that still carries a marker outside of code.
324///
325/// The walk above ends when no `vis` container is left, so a *parser*-level
326/// check would only re-ask a question that already answered itself. What it
327/// cannot answer is the case that matters: a body parsed under the wrong
328/// grammar, or Markdown parsed without [`extensions`]'s directive opt-in, has
329/// no containers to find and returns every region intact. Text is the only
330/// evidence left, so text is what this reads.
331///
332/// Code is excluded, and that exclusion is the whole reason this needs prov: a
333/// document *explaining* the syntax quotes it, and quoting is not declaring.
334/// [`prov::code_spans`] is the same code-awareness prov's own link scan uses,
335/// so a marker in a code span is prose here for exactly the reason it is prose
336/// there.
337fn residue_check(out: &str, format: ContentFormat) -> Result<(), Error> {
338    let code = prov::code_spans(out, format).unwrap_or_default();
339    let in_code = |at: usize| code.iter().any(|s| s.contains(&at));
340
341    for spelling in [":::vis", "::vis", ":vis["] {
342        let mut from = 0;
343        while let Some(rel) = out[from..].find(spelling) {
344            let at = from + rel;
345            if !in_code(at) {
346                let end = out[at..]
347                    .char_indices()
348                    .nth(40)
349                    .map_or(out.len(), |(i, _)| at + i);
350                return Err(Error::Residue {
351                    found: out[at..end].replace('\n', "\\n"),
352                });
353            }
354            from = at + spelling.len();
355        }
356    }
357    Ok(())
358}
359
360/// twig's name for a prov content format.
361///
362/// Spelled here because prov's own mapping is private, and prov is right to
363/// keep it so: it converts for its two FFI calls, not as a public claim about
364/// which twig format a `ContentFormat` *is*.
365fn twig_format(format: ContentFormat) -> twig::Format {
366    match format {
367        ContentFormat::Markdown => twig::Format::Markdown,
368        ContentFormat::Djot => twig::Format::Djot,
369        ContentFormat::Html => twig::Format::Html,
370    }
371}
372
373/// The parse extensions a grammar needs to see a region at all.
374///
375/// Markdown's generic directives are opt-in, and **the opt-in is
376/// load-bearing**: without it `:::vis{.family}` is a paragraph of literal text,
377/// no container matches, and the region publishes intact. Djot and HTML spell a
378/// region with syntax they always parse, so they need nothing.
379fn extensions(format: ContentFormat) -> MarkdownExtensions {
380    MarkdownExtensions {
381        directives: matches!(format, ContentFormat::Markdown),
382        ..MarkdownExtensions::default()
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn only<'a>(a: &'a [&'a str]) -> Audience<'a> {
391        Audience::Only(a)
392    }
393
394    #[test]
395    fn markdown_keeps_the_matching_region_and_drops_the_rest() {
396        let body = ":::vis{.public}\nSeen\n:::\n\n:::vis{.family}\nHidden\n:::\n";
397        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
398        assert!(out.contains("Seen"), "{out:?}");
399        assert!(!out.contains("Hidden"), "{out:?}");
400    }
401
402    #[test]
403    fn a_region_declaring_several_audiences_matches_any_of_them() {
404        let body = ":::vis{.family .friends}\nBoth\n:::\n";
405        for who in ["family", "friends"] {
406            let out = filter_body(body, ContentFormat::Markdown, only(&[who])).unwrap();
407            assert!(out.contains("Both"), "{who}: {out:?}");
408        }
409        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
410        assert!(!out.contains("Both"), "{out:?}");
411    }
412
413    #[test]
414    fn inline_regions_filter_too() {
415        let body = "a :vis[keep]{.public} b :vis[drop]{.family} c\n";
416        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
417        assert!(out.contains("keep"), "{out:?}");
418        assert!(!out.contains("drop"), "{out:?}");
419    }
420
421    /// The bug the text scanner had, and the reason this module parses. A
422    /// document *about* the syntax quotes it; a quoted marker is prose.
423    #[test]
424    fn a_marker_inside_a_code_span_is_prose() {
425        let body = "Write `:::vis{.family}` to scope a region.\n\n:::vis{.family}\nHidden\n:::\n";
426        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
427        assert!(out.contains("Write `:::vis{.family}`"), "{out:?}");
428        assert!(!out.contains("Hidden"), "{out:?}");
429    }
430
431    #[test]
432    fn nested_regions_resolve_from_the_inside_out() {
433        let body = ":::: vis{.public}\nouter\n\n:::vis{.family}\ninner\n:::\n::::\n";
434        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
435        assert!(out.contains("outer"), "{out:?}");
436        assert!(!out.contains("inner"), "{out:?}");
437    }
438
439    #[test]
440    fn html_regions_filter_by_class() {
441        let body = "<div class=\"vis public\">Seen</div>\n<div class=\"vis family\">Hidden</div>\n";
442        let out = filter_body(body, ContentFormat::Html, only(&["public"])).unwrap();
443        assert!(out.contains("Seen"), "{out:?}");
444        assert!(!out.contains("Hidden"), "{out:?}");
445    }
446
447    /// Djot carries a block's attributes on the line above it. Removing the
448    /// container without them would leave `{.vis .family}` standing — the
449    /// audience's name, published.
450    #[test]
451    fn djot_regions_take_their_attribute_line_with_them() {
452        let body = "{.vis .family}\n:::\nHidden\n:::\n";
453        let out = filter_body(body, ContentFormat::Djot, only(&["public"])).unwrap();
454        assert!(!out.contains("Hidden"), "{out:?}");
455        assert!(!out.contains("family"), "the audience name leaked: {out:?}");
456    }
457
458    #[test]
459    fn all_keeps_every_region_and_removes_every_marker() {
460        let body = ":::vis{.public}\nA\n:::\n\n:::vis{.family}\nB\n:::\n";
461        let out = filter_body(body, ContentFormat::Markdown, Audience::All).unwrap();
462        assert!(out.contains('A') && out.contains('B'), "{out:?}");
463        assert!(!out.contains("vis"), "{out:?}");
464    }
465
466    /// No audience is not "every audience". A document declaring nothing is
467    /// visible to no one, and a region is judged the same way.
468    #[test]
469    fn an_empty_audience_list_keeps_nothing() {
470        let body = ":::vis{.public}\nA\n:::\n";
471        let out = filter_body(body, ContentFormat::Markdown, only(&[])).unwrap();
472        assert!(!out.contains('A'), "{out:?}");
473    }
474
475    /// A body with no marker never reaches the parser.
476    #[test]
477    fn a_body_with_no_regions_is_returned_unchanged() {
478        let body = "# Title\n\nJust prose.\n";
479        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
480        assert_eq!(out, body);
481    }
482
483    /// The migration's sharp edge, and the reason it is safe. The old bare-key
484    /// spelling declares no *class*, so it matches no audience and its content
485    /// is dropped — content vanishes rather than leaking. The marker still goes,
486    /// so this does not trip the residue check.
487    #[test]
488    fn the_old_bare_key_spelling_drops_rather_than_leaks() {
489        let body = ":::vis{public}\nHidden\n:::\n";
490        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
491        assert!(
492            !out.contains("Hidden"),
493            "old spelling must not publish: {out:?}"
494        );
495    }
496
497    /// twig does not nest inline directives, and the half-parsed result would
498    /// otherwise publish the text it was scoping with only the marker removed.
499    /// Refused by shape, since no marker survives for the residue check to find.
500    #[test]
501    fn a_nested_inline_region_is_refused_rather_than_half_filtered() {
502        let body = "A :vis[secret :vis[inner]{.public} end]{.family}\n";
503        let err = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap_err();
504        assert!(matches!(err, Error::Malformed { .. }), "{err:?}");
505    }
506
507    /// The same nesting spelled with block regions is fine — the gap is inline
508    /// directives only.
509    #[test]
510    fn nested_block_regions_are_not_affected_by_that_gap() {
511        let body = ":::: vis{.public}\nouter\n\n:::vis{.family}\ninner\n:::\n::::\n";
512        let out = filter_body(body, ContentFormat::Markdown, only(&["public"])).unwrap();
513        assert!(out.contains("outer") && !out.contains("inner"), "{out:?}");
514    }
515
516    /// The backstop, exercised directly: a marker the walk did not account for
517    /// refuses the body instead of publishing it.
518    #[test]
519    fn a_surviving_marker_is_refused() {
520        let err = residue_check(
521            "text\n:::vis{.family}\nHidden\n:::\n",
522            ContentFormat::Markdown,
523        )
524        .unwrap_err();
525        assert!(matches!(err, Error::Residue { .. }), "{err:?}");
526    }
527}