Skip to main content

parley_markup/
lib.rs

1//! Renders untrusted markdown into a typed tree that cannot carry markup.
2//!
3//! Message bodies are attacker-controlled. In the CLI they land in a
4//! terminal; in a webview they land somewhere that can execute code, and
5//! a sender who achieves script execution there is inside the trust
6//! boundary and can read and exfiltrate every conversation. That is
7//! strictly worse than compromising the relay, which only ever sees
8//! ciphertext.
9//!
10//! So this crate never produces an HTML string. It produces [`Node`] and
11//! [`Inline`] values that a renderer maps to elements, which means there
12//! is no string for an injection to hide in and no `innerHTML` at the far
13//! end. Three rules do the work:
14//!
15//! 1. **Raw HTML is text, not markup.** `pulldown-cmark` emits embedded
16//!    HTML as its own event; those become [`Inline::Text`] verbatim. A
17//!    body containing `<script>` renders those characters on screen.
18//! 2. **Link targets are allowlisted** to `http`, `https`, and `mailto`.
19//!    Anything else (`javascript:`, `data:`, `vbscript:`, `file:`) loses
20//!    its link and keeps its text, so the content survives and the
21//!    navigation does not.
22//! 3. **Remote images are not fetched.** This is privacy, not just
23//!    safety: an inlined remote image is a read receipt telling the
24//!    sender when you opened a message and from which IP. They arrive as
25//!    [`ImageSource::Blocked`] for the UI to offer as click-to-load.
26
27use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
28use serde::{Deserialize, Serialize};
29
30/// Schemes a link may navigate to. Everything else is stripped.
31const ALLOWED_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum Node {
36    Paragraph {
37        content: Vec<Inline>,
38    },
39    Heading {
40        level: u8,
41        content: Vec<Inline>,
42    },
43    List {
44        ordered: bool,
45        items: Vec<Vec<Node>>,
46    },
47    CodeBlock {
48        language: Option<String>,
49        code: String,
50    },
51    BlockQuote {
52        content: Vec<Node>,
53    },
54    Rule,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum Inline {
60    Text {
61        text: String,
62    },
63    Code {
64        code: String,
65    },
66    Emphasis {
67        content: Vec<Inline>,
68    },
69    Strong {
70        content: Vec<Inline>,
71    },
72    Strikethrough {
73        content: Vec<Inline>,
74    },
75    /// A link whose target already passed the scheme allowlist.
76    Link {
77        href: String,
78        content: Vec<Inline>,
79    },
80    Image {
81        source: ImageSource,
82        alt: String,
83    },
84    SoftBreak,
85    HardBreak,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(tag = "kind", rename_all = "snake_case")]
90pub enum ImageSource {
91    /// Already on disk and end-to-end encrypted in transit: a received
92    /// file. Safe to render inline; fetching it tells nobody anything.
93    Local { path: String },
94    /// Off-machine. Never fetched automatically, because loading it would
95    /// report back to whoever controls that host. Retained so a UI can
96    /// offer an explicit click-to-load.
97    Blocked { url: String },
98}
99
100/// Parse `markdown` into a tree that carries no markup.
101#[must_use]
102pub fn render(markdown: &str) -> Vec<Node> {
103    let mut options = Options::empty();
104    options.insert(Options::ENABLE_STRIKETHROUGH);
105    options.insert(Options::ENABLE_TABLES);
106    options.insert(Options::ENABLE_FOOTNOTES);
107    // Deliberately NOT enabling any option that widens what the parser
108    // will treat as markup.
109
110    let mut blocks = Vec::new();
111    let mut parser = Parser::new_ext(markdown, options).peekable();
112    while let Some(event) = parser.next() {
113        if let Some(node) = block(&mut parser, event) {
114            blocks.push(node);
115        }
116    }
117    blocks
118}
119
120type EventStream<'a> = std::iter::Peekable<Parser<'a>>;
121
122fn block(p: &mut EventStream<'_>, event: Event<'_>) -> Option<Node> {
123    match event {
124        Event::Start(Tag::Paragraph) => Some(Node::Paragraph {
125            content: inlines(p, TagEnd::Paragraph),
126        }),
127        Event::Start(Tag::Heading { level, .. }) => Some(Node::Heading {
128            level: heading_level(level),
129            content: inlines(p, TagEnd::Heading(level)),
130        }),
131        Event::Start(Tag::CodeBlock(kind)) => {
132            let language = match kind {
133                pulldown_cmark::CodeBlockKind::Fenced(l) if !l.is_empty() => Some(l.to_string()),
134                _ => None,
135            };
136            let mut code = String::new();
137            for e in p.by_ref() {
138                match e {
139                    // Inside a fence everything is literal, including
140                    // anything that looks like a tag.
141                    Event::Text(t) | Event::Html(t) | Event::InlineHtml(t) => code.push_str(&t),
142                    Event::End(TagEnd::CodeBlock) => break,
143                    _ => {}
144                }
145            }
146            Some(Node::CodeBlock { language, code })
147        }
148        Event::Start(Tag::BlockQuote(_)) => {
149            let mut content = Vec::new();
150            while let Some(e) = p.next() {
151                if matches!(e, Event::End(TagEnd::BlockQuote(_))) {
152                    break;
153                }
154                if let Some(n) = block(p, e) {
155                    content.push(n);
156                }
157            }
158            Some(Node::BlockQuote { content })
159        }
160        Event::Start(Tag::List(start)) => {
161            let ordered = start.is_some();
162            let mut items = Vec::new();
163            while let Some(e) = p.next() {
164                match e {
165                    Event::Start(Tag::Item) => {
166                        let mut item = Vec::new();
167                        while let Some(ie) = p.next() {
168                            if matches!(ie, Event::End(TagEnd::Item)) {
169                                break;
170                            }
171                            if opens_a_block(&ie) {
172                                if let Some(n) = block(p, ie) {
173                                    item.push(n);
174                                }
175                                continue;
176                            }
177                            // Inline content. A tight list item arrives as
178                            // loose inlines with no wrapping paragraph, so
179                            // accumulate into one rather than starting a
180                            // fresh block per run: otherwise `- a **b** c`
181                            // renders as three stacked lines.
182                            let produced = inline_events(p, ie);
183                            if produced.is_empty() {
184                                continue;
185                            }
186                            match item.last_mut() {
187                                Some(Node::Paragraph { content }) => content.extend(produced),
188                                _ => item.push(Node::Paragraph { content: produced }),
189                            }
190                        }
191                        items.push(item);
192                    }
193                    Event::End(TagEnd::List(_)) => break,
194                    _ => {}
195                }
196            }
197            Some(Node::List { ordered, items })
198        }
199        Event::Rule => Some(Node::Rule),
200        // A stray inline at block level (common for one-line bodies).
201        other => inline_of(&other).map(|i| Node::Paragraph { content: vec![i] }),
202    }
203}
204
205fn inlines(p: &mut EventStream<'_>, until: TagEnd) -> Vec<Inline> {
206    let mut out = Vec::new();
207    while let Some(e) = p.next() {
208        if e == Event::End(until) {
209            break;
210        }
211        out.extend(inline_events(p, e));
212    }
213    out
214}
215
216/// Turn one inline-level event into the inlines it produces.
217///
218/// Returns a `Vec` because a rejected link splices its text into the
219/// surrounding run rather than contributing a single node. Shared with
220/// the list-item path, which has to recognise inline events itself.
221fn inline_events(p: &mut EventStream<'_>, e: Event<'_>) -> Vec<Inline> {
222    match e {
223        Event::Start(Tag::Emphasis) => vec![Inline::Emphasis {
224            content: inlines(p, TagEnd::Emphasis),
225        }],
226        Event::Start(Tag::Strong) => vec![Inline::Strong {
227            content: inlines(p, TagEnd::Strong),
228        }],
229        Event::Start(Tag::Strikethrough) => vec![Inline::Strikethrough {
230            content: inlines(p, TagEnd::Strikethrough),
231        }],
232        Event::Start(Tag::Link { dest_url, .. }) => {
233            let content = inlines(p, TagEnd::Link);
234            match sanitize_href(&dest_url) {
235                // Target rejected: keep what it said, drop where it went.
236                None => content,
237                Some(href) => vec![Inline::Link { href, content }],
238            }
239        }
240        Event::Start(Tag::Image { dest_url, .. }) => {
241            let alt = plain_text(&inlines(p, TagEnd::Image));
242            vec![Inline::Image {
243                source: image_source(&dest_url),
244                alt,
245            }]
246        }
247        other => inline_of(&other).into_iter().collect(),
248    }
249}
250
251/// Whether an event opens a block, as opposed to inline content that
252/// belongs inside the block currently being built.
253fn opens_a_block(e: &Event<'_>) -> bool {
254    matches!(
255        e,
256        Event::Start(
257            Tag::Paragraph
258                | Tag::Heading { .. }
259                | Tag::CodeBlock(_)
260                | Tag::BlockQuote(_)
261                | Tag::List(_)
262        ) | Event::Rule
263    )
264}
265
266fn inline_of(e: &Event<'_>) -> Option<Inline> {
267    match e {
268        Event::Text(t) => Some(Inline::Text {
269            text: t.to_string(),
270        }),
271        Event::Code(c) => Some(Inline::Code {
272            code: c.to_string(),
273        }),
274        // The load-bearing case: embedded HTML becomes literal text, so
275        // `<script>` is something you read, not something that runs.
276        Event::Html(h) | Event::InlineHtml(h) => Some(Inline::Text {
277            text: h.to_string(),
278        }),
279        Event::SoftBreak => Some(Inline::SoftBreak),
280        Event::HardBreak => Some(Inline::HardBreak),
281        _ => None,
282    }
283}
284
285/// Accept a link target only if its scheme is allowlisted, or if it has
286/// no scheme at all (a relative or fragment link, which cannot navigate
287/// off-origin from a bundled page).
288fn sanitize_href(raw: &str) -> Option<String> {
289    let trimmed = raw.trim();
290    if trimmed.is_empty() {
291        return None;
292    }
293    // Control characters (including NUL and newlines) are how
294    // `java\0script:` style bypasses are smuggled past naive checks.
295    if trimmed.chars().any(char::is_control) {
296        return None;
297    }
298    match scheme_of(trimmed) {
299        None => Some(trimmed.to_owned()),
300        Some(scheme) => ALLOWED_SCHEMES
301            .contains(&scheme.as_str())
302            .then(|| trimmed.to_owned()),
303    }
304}
305
306/// The scheme of `s`, lowercased, if it has one. A colon appearing after
307/// a `/`, `?`, or `#` belongs to a path, not a scheme.
308fn scheme_of(s: &str) -> Option<String> {
309    let end = s.find(':')?;
310    let candidate = &s[..end];
311    if candidate.is_empty() || s[..end].contains(['/', '?', '#']) {
312        return None;
313    }
314    // Per RFC 3986 a scheme is ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ).
315    let mut chars = candidate.chars();
316    let first = chars.next()?;
317    if !first.is_ascii_alphabetic() {
318        return None;
319    }
320    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
321        return None;
322    }
323    Some(candidate.to_ascii_lowercase())
324}
325
326fn image_source(raw: &str) -> ImageSource {
327    let trimmed = raw.trim();
328    match scheme_of(trimmed) {
329        // No scheme: a path to something already on disk.
330        None => ImageSource::Local {
331            path: trimmed.to_owned(),
332        },
333        Some(s) if s == "file" => ImageSource::Local {
334            path: trimmed.to_owned(),
335        },
336        _ => ImageSource::Blocked {
337            url: trimmed.to_owned(),
338        },
339    }
340}
341
342fn plain_text(inlines: &[Inline]) -> String {
343    let mut s = String::new();
344    for i in inlines {
345        match i {
346            Inline::Text { text } => s.push_str(text),
347            Inline::Code { code } => s.push_str(code),
348            Inline::Emphasis { content }
349            | Inline::Strong { content }
350            | Inline::Strikethrough { content }
351            | Inline::Link { content, .. } => s.push_str(&plain_text(content)),
352            Inline::SoftBreak | Inline::HardBreak => s.push(' '),
353            Inline::Image { alt, .. } => s.push_str(alt),
354        }
355    }
356    s
357}
358
359fn heading_level(l: HeadingLevel) -> u8 {
360    match l {
361        HeadingLevel::H1 => 1,
362        HeadingLevel::H2 => 2,
363        HeadingLevel::H3 => 3,
364        HeadingLevel::H4 => 4,
365        HeadingLevel::H5 => 5,
366        HeadingLevel::H6 => 6,
367    }
368}
369
370#[cfg(test)]
371#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
372mod tests {
373    use super::*;
374
375    /// Flatten to the text a reader would see, so assertions can check
376    /// that content survived without depending on tree shape.
377    fn visible(nodes: &[Node]) -> String {
378        fn walk(nodes: &[Node], out: &mut String) {
379            for n in nodes {
380                match n {
381                    Node::Paragraph { content } | Node::Heading { content, .. } => {
382                        out.push_str(&plain_text(content));
383                        out.push('\n');
384                    }
385                    Node::CodeBlock { code, .. } => out.push_str(code),
386                    Node::BlockQuote { content } => walk(content, out),
387                    Node::List { items, .. } => {
388                        for i in items {
389                            walk(i, out);
390                        }
391                    }
392                    Node::Rule => out.push('\n'),
393                }
394            }
395        }
396        let mut s = String::new();
397        walk(nodes, &mut s);
398        s
399    }
400
401    fn links(nodes: &[Node]) -> Vec<String> {
402        fn walk_inline(is: &[Inline], out: &mut Vec<String>) {
403            for i in is {
404                match i {
405                    Inline::Link { href, content } => {
406                        out.push(href.clone());
407                        walk_inline(content, out);
408                    }
409                    Inline::Emphasis { content }
410                    | Inline::Strong { content }
411                    | Inline::Strikethrough { content } => walk_inline(content, out),
412                    _ => {}
413                }
414            }
415        }
416        fn walk(nodes: &[Node], out: &mut Vec<String>) {
417            for n in nodes {
418                match n {
419                    Node::Paragraph { content } | Node::Heading { content, .. } => {
420                        walk_inline(content, out);
421                    }
422                    Node::BlockQuote { content } => walk(content, out),
423                    Node::List { items, .. } => {
424                        for i in items {
425                            walk(i, out);
426                        }
427                    }
428                    _ => {}
429                }
430            }
431        }
432        let mut v = Vec::new();
433        walk(nodes, &mut v);
434        v
435    }
436
437    fn images(nodes: &[Node]) -> Vec<ImageSource> {
438        let mut out = Vec::new();
439        fn walk_inline(is: &[Inline], out: &mut Vec<ImageSource>) {
440            for i in is {
441                match i {
442                    Inline::Image { source, .. } => out.push(source.clone()),
443                    Inline::Emphasis { content }
444                    | Inline::Strong { content }
445                    | Inline::Strikethrough { content }
446                    | Inline::Link { content, .. } => walk_inline(content, out),
447                    _ => {}
448                }
449            }
450        }
451        for n in nodes {
452            if let Node::Paragraph { content } | Node::Heading { content, .. } = n {
453                walk_inline(content, &mut out);
454            }
455        }
456        out
457    }
458
459    #[test]
460    fn ordinary_markdown_still_formats() {
461        let nodes = render("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
462        assert!(matches!(nodes[0], Node::Heading { level: 1, .. }));
463        let seen = visible(&nodes);
464        assert!(seen.contains("Title"), "{seen}");
465        assert!(seen.contains("bold"), "{seen}");
466        assert!(seen.contains("one"), "{seen}");
467        assert!(
468            nodes.iter().any(|n| matches!(n, Node::List { .. })),
469            "expected a list: {nodes:#?}"
470        );
471    }
472
473    #[test]
474    fn fenced_code_survives_verbatim() {
475        let nodes = render("```rust\nlet x = 1;\n```\n");
476        let Some(Node::CodeBlock { language, code }) = nodes.first() else {
477            panic!("expected a code block, got {nodes:#?}");
478        };
479        assert_eq!(language.as_deref(), Some("rust"));
480        assert_eq!(code, "let x = 1;\n");
481    }
482
483    /// The whole point. Embedded HTML must be readable, never runnable.
484    #[test]
485    fn raw_html_becomes_literal_text() {
486        for body in [
487            "<script>alert(1)</script>",
488            "<img src=x onerror=alert(1)>",
489            "<iframe src=\"https://evil.example\"></iframe>",
490            "hello <b>there</b>",
491            "<svg/onload=alert(1)>",
492        ] {
493            let nodes = render(body);
494            let seen = visible(&nodes);
495            assert!(
496                seen.contains('<'),
497                "markup should survive as text for {body:?}, got {seen:?}"
498            );
499            // Nothing anywhere in the tree claims to be a link.
500            assert!(
501                links(&nodes).is_empty(),
502                "raw html produced a link for {body:?}"
503            );
504        }
505    }
506
507    #[test]
508    fn dangerous_link_schemes_lose_the_link_and_keep_the_text() {
509        for body in [
510            "[click](javascript:alert(1))",
511            "[click](JaVaScRiPt:alert(1))",
512            "[click](  javascript:alert(1)  )",
513            "[click](data:text/html;base64,PHNjcmlwdD4=)",
514            "[click](vbscript:msgbox)",
515            "[click](file:///etc/passwd)",
516        ] {
517            let nodes = render(body);
518            assert!(
519                links(&nodes).is_empty(),
520                "{body:?} should not produce a link, got {:?}",
521                links(&nodes)
522            );
523            assert!(
524                visible(&nodes).contains("click"),
525                "{body:?} should keep its text"
526            );
527        }
528    }
529
530    #[test]
531    fn control_characters_cannot_smuggle_a_scheme() {
532        // A NUL or newline inside the scheme is the classic way past a
533        // naive `starts_with("javascript:")` check.
534        for body in [
535            "[x](java\u{0}script:alert(1))",
536            "[x](java\nscript:alert(1))",
537            "[x](\u{0}javascript:alert(1))",
538        ] {
539            let nodes = render(body);
540            assert!(
541                links(&nodes).is_empty(),
542                "{body:?} smuggled a link through: {:?}",
543                links(&nodes)
544            );
545        }
546    }
547
548    #[test]
549    fn ordinary_links_are_kept() {
550        let nodes = render("[docs](https://example.com/a?b=1#c) and [mail](mailto:a@b.c)");
551        let hrefs = links(&nodes);
552        assert!(
553            hrefs.contains(&"https://example.com/a?b=1#c".to_owned()),
554            "{hrefs:?}"
555        );
556        assert!(hrefs.contains(&"mailto:a@b.c".to_owned()), "{hrefs:?}");
557    }
558
559    #[test]
560    fn relative_and_fragment_links_are_kept() {
561        // No scheme means it cannot navigate off a bundled page.
562        let nodes = render("[a](#section) [b](./notes.md) [c](/abs/path)");
563        assert_eq!(links(&nodes).len(), 3, "{:?}", links(&nodes));
564    }
565
566    /// A remote image is a read receipt: fetching it tells the sender
567    /// when the message was opened, and from which IP.
568    #[test]
569    fn remote_images_are_blocked_local_ones_are_not() {
570        let nodes = render("![tracker](https://evil.example/pixel.png)");
571        assert_eq!(
572            images(&nodes),
573            vec![ImageSource::Blocked {
574                url: "https://evil.example/pixel.png".to_owned()
575            }]
576        );
577
578        let nodes = render("![diagram](./diagram.png)");
579        assert_eq!(
580            images(&nodes),
581            vec![ImageSource::Local {
582                path: "./diagram.png".to_owned()
583            }]
584        );
585    }
586
587    #[test]
588    fn image_alt_text_is_plain() {
589        let nodes = render("![<script>x</script>](https://e.example/a.png)");
590        let Some(Node::Paragraph { content }) = nodes.first() else {
591            panic!("expected paragraph, got {nodes:#?}");
592        };
593        let Some(Inline::Image { alt, .. }) = content.first() else {
594            panic!("expected image, got {content:#?}");
595        };
596        assert!(alt.contains("script"), "alt should keep text: {alt:?}");
597    }
598
599    #[test]
600    fn a_plain_message_is_still_a_paragraph() {
601        // The overwhelmingly common case: one line of prose.
602        let nodes = render("just a normal message");
603        assert_eq!(visible(&nodes).trim(), "just a normal message");
604    }
605
606    #[test]
607    fn empty_input_is_empty_output() {
608        assert!(render("").is_empty());
609        assert!(render("   \n  \n").is_empty());
610    }
611
612    #[test]
613    fn nested_structures_do_not_lose_content() {
614        let nodes = render("> quoted **bold**\n>\n> - item one\n> - item two\n");
615        let seen = visible(&nodes);
616        assert!(seen.contains("quoted"), "{seen}");
617        assert!(seen.contains("bold"), "{seen}");
618        assert!(seen.contains("item one"), "{seen}");
619        assert!(seen.contains("item two"), "{seen}");
620    }
621
622    #[test]
623    fn scheme_detection_does_not_trip_on_paths() {
624        // A colon after a slash belongs to the path, not a scheme.
625        assert_eq!(scheme_of("https://a.example/x:y"), Some("https".into()));
626        assert_eq!(scheme_of("/path/to:thing"), None);
627        assert_eq!(scheme_of("#frag:ment"), None);
628        assert_eq!(scheme_of("no-colon-here"), None);
629        assert_eq!(scheme_of("1nvalid:x"), None);
630    }
631}
632
633#[cfg(test)]
634#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
635mod list_tests {
636    use super::*;
637
638    /// A bullet containing bold, code, or a link is one line, not three.
639    ///
640    /// Tight list items arrive as loose inlines with no wrapping
641    /// paragraph. Starting a new block per run made `- a **b** c` render
642    /// as three stacked lines, which is how most real messages look.
643    #[test]
644    fn inline_formatting_does_not_split_a_list_item() {
645        let nodes = render("- plain **bold** and `code` and [a link](https://e.example) tail\n");
646        let Some(Node::List { items, .. }) = nodes.first() else {
647            panic!("expected a list, got {nodes:#?}");
648        };
649        assert_eq!(items.len(), 1, "one bullet");
650        assert_eq!(
651            items[0].len(),
652            1,
653            "a bullet with inline formatting must stay one block, got {:#?}",
654            items[0]
655        );
656        let Node::Paragraph { content } = &items[0][0] else {
657            panic!("expected a paragraph, got {:#?}", items[0][0]);
658        };
659        let kinds: Vec<&str> = content
660            .iter()
661            .map(|i| match i {
662                Inline::Text { .. } => "text",
663                Inline::Strong { .. } => "strong",
664                Inline::Code { .. } => "code",
665                Inline::Link { .. } => "link",
666                _ => "other",
667            })
668            .collect();
669        assert!(kinds.contains(&"strong"), "{kinds:?}");
670        assert!(kinds.contains(&"code"), "{kinds:?}");
671        assert!(kinds.contains(&"link"), "{kinds:?}");
672    }
673
674    /// Emoji are ordinary text and must survive untouched, including the
675    /// multi-codepoint ones with variation selectors.
676    #[test]
677    fn emoji_survive_intact() {
678        let nodes = render("- 🔒 locked\n- ✍️ writing\n- ⚡ fast\n");
679        let Some(Node::List { items, .. }) = nodes.first() else {
680            panic!("expected a list");
681        };
682        assert_eq!(items.len(), 3);
683        let first = match &items[0][0] {
684            Node::Paragraph { content } => content,
685            other => panic!("expected paragraph, got {other:#?}"),
686        };
687        let Inline::Text { text } = &first[0] else {
688            panic!("expected text");
689        };
690        assert!(text.starts_with('🔒'), "{text:?}");
691        // The pen emoji carries a variation selector; it must not be
692        // split or dropped.
693        let second = match &items[1][0] {
694            Node::Paragraph { content } => content,
695            other => panic!("expected paragraph, got {other:#?}"),
696        };
697        let Inline::Text { text } = &second[0] else {
698            panic!("expected text");
699        };
700        assert!(text.contains('\u{270D}'), "{text:?}");
701    }
702
703    /// A genuinely multi-block item (a nested list, say) still nests.
704    #[test]
705    fn a_list_item_with_real_blocks_keeps_them() {
706        let nodes = render("- top\n  - nested\n");
707        let Some(Node::List { items, .. }) = nodes.first() else {
708            panic!("expected a list");
709        };
710        assert!(
711            items[0].iter().any(|n| matches!(n, Node::List { .. })),
712            "nested list should survive: {:#?}",
713            items[0]
714        );
715    }
716}