Skip to main content

magi/
md.rs

1//! GFM markdown, parsed into a serializable node tree instead of HTML.
2//!
3//! The web UI's one hard rule is that DOM is built with `createElement` and
4//! `textContent` and never with `innerHTML` — an AI-authored chat reply, a
5//! question's reasoning, and an operator's task instruction are all arbitrary
6//! text, and any of them could contain a `<script>` tag. That rule used to
7//! force the front end into `assets/ui/app.js`'s own tiny hand-rolled
8//! markdown reader, which understood four constructs and left everything
9//! else — tables, task lists, links — as literal asterisks and brackets.
10//!
11//! This module moves the actual parsing to [`comrak`], a full GFM
12//! implementation, and hands the client a tree of [`Node`] instead of a
13//! string of HTML. The client walks the tree and builds elements directly;
14//! there is never a markup string to insert, so the no-`innerHTML` rule holds
15//! even though the markdown support is now complete.
16//!
17//! Two things are deliberately stricter than the source markdown, both
18//! because this tree can carry attacker-authored text (an AI writes chat
19//! turns and question detail) into a browser with no server-side sanitizer
20//! standing between them:
21//!
22//! - [`normalize_link`] keeps only `http:`/`https:` link destinations.
23//! - [`normalize_image`] keeps only `data:` image URIs and, when the
24//!   markdown came from a question, relative filenames resolved against that
25//!   question's existing sandboxed panel asset route (see
26//!   [`ImageBase::QuestionPanel`]).
27//!
28//! Raw HTML in the source (a `<script>` block, an `<img onerror=…>`) is never
29//! interpreted: [`to_nodes`] turns it into a [`Node::Text`] carrying the
30//! literal characters, so a client that renders it with `textContent` shows
31//! the tag rather than running it.
32
33use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
34use comrak::{Arena, Options, parse_document};
35use serde::Serialize;
36
37use crate::ask::valid_asset_name;
38
39/// How a relative image path in the source markdown is allowed to resolve.
40///
41/// A relative path (`![shot](shot.png)`) names no host and no scheme, so on
42/// its own it is not renderable at all — magi's web UI never serves files
43/// from an arbitrary directory. The one place a relative path is meaningful
44/// is a question's `detail`, where it names one of that question's own panel
45/// assets, already reachable at `GET /api/questions/{id}/panel/{name}`. Every
46/// other caller passes [`ImageBase::None`], under which a relative path is
47/// rejected exactly like a `file:` URL.
48#[derive(Debug, Clone)]
49pub enum ImageBase {
50    /// No question backs this text, so a relative image path cannot be
51    /// resolved.
52    None,
53    /// This text is a question's `detail`; a relative image path resolves
54    /// against that question's panel asset route.
55    QuestionPanel {
56        /// The question id the panel route is scoped to.
57        id: String,
58    },
59}
60
61/// Column alignment of a table cell, per the GFM table extension.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Align {
65    /// No alignment was requested for this column.
66    None,
67    /// `:---`
68    Left,
69    /// `:---:`
70    Center,
71    /// `---:`
72    Right,
73}
74
75fn align_of(a: TableAlignment) -> Align {
76    match a {
77        TableAlignment::None => Align::None,
78        TableAlignment::Left => Align::Left,
79        TableAlignment::Center => Align::Center,
80        TableAlignment::Right => Align::Right,
81    }
82}
83
84/// One cell of a [`Node::Table`] row.
85#[derive(Debug, Clone, PartialEq, Serialize)]
86pub struct TableCell {
87    /// Is this cell part of the header row?
88    pub header: bool,
89    /// This cell's column alignment.
90    pub align: Align,
91    /// The cell's inline content.
92    pub children: Vec<Node>,
93}
94
95/// One node of parsed markdown.
96///
97/// Shaped for a client to walk and turn into DOM nodes directly — every
98/// variant is either a container (`children`, or for a list or table, a
99/// nested collection) or a leaf that carries exactly the data needed to
100/// build one element. There is no HTML anywhere in this type; see the module
101/// documentation for the two places that turn attacker-controlled markup
102/// into plain [`Node::Text`] instead of a live link or image.
103#[derive(Debug, Clone, PartialEq, Serialize)]
104#[serde(tag = "type", rename_all = "snake_case")]
105pub enum Node {
106    /// A paragraph.
107    Paragraph {
108        /// Inline content.
109        children: Vec<Node>,
110    },
111    /// A heading.
112    Heading {
113        /// Heading level, 1 through 6.
114        level: u8,
115        /// Inline content.
116        children: Vec<Node>,
117    },
118    /// An unordered (bullet) list.
119    BulletList {
120        /// The list's items. Always [`Node::ListItem`].
121        items: Vec<Node>,
122    },
123    /// An ordered (numbered) list.
124    OrderedList {
125        /// The number the list starts counting from.
126        start: u32,
127        /// The list's items. Always [`Node::ListItem`].
128        items: Vec<Node>,
129    },
130    /// One item of a list, or of a GFM task list.
131    ListItem {
132        /// `Some(true)` for a checked task item, `Some(false)` for an
133        /// unchecked one, `None` for a plain list item.
134        checked: Option<bool>,
135        /// The item's block content, which may include a nested list.
136        children: Vec<Node>,
137    },
138    /// A GFM table.
139    Table {
140        /// One alignment per column, in column order.
141        align: Vec<Align>,
142        /// Rows in reading order; the first is the header row.
143        rows: Vec<Vec<TableCell>>,
144    },
145    /// A block quote.
146    BlockQuote {
147        /// The quote's block content.
148        children: Vec<Node>,
149    },
150    /// A horizontal rule (`---`).
151    ThematicBreak,
152    /// A fenced or indented code block.
153    ///
154    /// Never syntax-highlighted: magi does not add a highlighting
155    /// dependency, so `lang` is carried only as a label for the client to
156    /// show, not as a hint the server has already acted on.
157    CodeBlock {
158        /// The fence's info string (e.g. `rust`), if the source gave one.
159        lang: Option<String>,
160        /// The block's literal text.
161        code: String,
162    },
163    /// An inline code span.
164    Code {
165        /// The span's literal text.
166        code: String,
167    },
168    /// Emphasized (`*italic*`) inline content.
169    Emphasis {
170        /// Inline content.
171        children: Vec<Node>,
172    },
173    /// Strongly emphasized (`**bold**`) inline content.
174    Strong {
175        /// Inline content.
176        children: Vec<Node>,
177    },
178    /// Struck-through (`~~text~~`) inline content.
179    Strikethrough {
180        /// Inline content.
181        children: Vec<Node>,
182    },
183    /// A link. Only ever produced for an `http:`/`https:` destination; any
184    /// other scheme becomes [`Node::Text`] instead, see [`normalize_link`].
185    Link {
186        /// The link's destination.
187        href: String,
188        /// The link's inline text.
189        children: Vec<Node>,
190    },
191    /// An image. Only ever produced for a `data:` image URI or a resolved
192    /// question panel asset; anything else becomes [`Node::Text`] instead,
193    /// see [`normalize_image`].
194    Image {
195        /// The image's resolved source.
196        src: String,
197        /// The image's alt text.
198        alt: String,
199    },
200    /// A soft line break: a single newline in the source, conventionally
201    /// rendered as a space or an ordinary wrap.
202    SoftBreak,
203    /// A hard line break: two trailing spaces, or a trailing backslash, in
204    /// the source.
205    LineBreak,
206    /// Plain text.
207    ///
208    /// Also stands in for anything [`to_nodes`] refuses to render as markup:
209    /// raw HTML from the source, a link with a disallowed scheme, an image
210    /// with a disallowed source.
211    Text {
212        /// The text itself.
213        value: String,
214    },
215}
216
217/// Parse `text` as GFM markdown into a node tree.
218///
219/// Enables the GFM extensions an operator or an agent's prose actually uses —
220/// tables, task lists, strikethrough, autolinked bare URLs — and nothing
221/// that changes how plain prose reads (no smart quotes, no superscript).
222/// `image_base` controls whether a relative image path in `text` can resolve
223/// to anything; see [`ImageBase`].
224pub fn to_nodes(text: &str, image_base: &ImageBase) -> Vec<Node> {
225    let arena = Arena::new();
226    let mut options = Options::default();
227    options.extension.table = true;
228    options.extension.strikethrough = true;
229    options.extension.tasklist = true;
230    options.extension.autolink = true;
231    let root = parse_document(&arena, text, &options);
232    children_of(root, image_base)
233}
234
235fn children_of<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Vec<Node> {
236    node.children()
237        .filter_map(|child| convert(child, image_base))
238        .collect()
239}
240
241fn convert<'a>(node: &'a AstNode<'a>, image_base: &ImageBase) -> Option<Node> {
242    let value = node.data.borrow().value.clone();
243    Some(match value {
244        NodeValue::Paragraph => Node::Paragraph {
245            children: children_of(node, image_base),
246        },
247        NodeValue::Heading(h) => Node::Heading {
248            level: h.level,
249            children: children_of(node, image_base),
250        },
251        NodeValue::List(l) => {
252            let items = children_of(node, image_base);
253            if l.list_type == ListType::Ordered {
254                Node::OrderedList {
255                    start: l.start as u32,
256                    items,
257                }
258            } else {
259                Node::BulletList { items }
260            }
261        }
262        NodeValue::Item(_) => Node::ListItem {
263            checked: None,
264            children: children_of(node, image_base),
265        },
266        NodeValue::TaskItem(t) => Node::ListItem {
267            checked: Some(t.symbol.is_some()),
268            children: children_of(node, image_base),
269        },
270        NodeValue::BlockQuote => Node::BlockQuote {
271            children: children_of(node, image_base),
272        },
273        NodeValue::ThematicBreak => Node::ThematicBreak,
274        NodeValue::CodeBlock(cb) => Node::CodeBlock {
275            lang: (!cb.info.is_empty()).then_some(cb.info),
276            code: cb.literal,
277        },
278        NodeValue::Code(c) => Node::Code { code: c.literal },
279        // Raw HTML is never interpreted: the literal source text becomes a
280        // text node, so a client rendering it with `textContent` shows the
281        // tag's characters instead of running or styling anything.
282        NodeValue::HtmlBlock(h) => Node::Text { value: h.literal },
283        NodeValue::HtmlInline(s) => Node::Text { value: s },
284        NodeValue::Text(s) => Node::Text {
285            value: s.into_owned(),
286        },
287        NodeValue::SoftBreak => Node::SoftBreak,
288        NodeValue::LineBreak => Node::LineBreak,
289        NodeValue::Emph => Node::Emphasis {
290            children: children_of(node, image_base),
291        },
292        NodeValue::Strong => Node::Strong {
293            children: children_of(node, image_base),
294        },
295        NodeValue::Strikethrough => Node::Strikethrough {
296            children: children_of(node, image_base),
297        },
298        NodeValue::Link(l) => normalize_link(&l.url, children_of(node, image_base)),
299        NodeValue::Image(l) => {
300            let alt = plain_text(&children_of(node, image_base));
301            normalize_image(&l.url, alt, image_base)
302        }
303        NodeValue::Table(t) => {
304            let rows = node
305                .children()
306                .map(|row| table_row(row, &t.alignments, image_base))
307                .collect();
308            Node::Table {
309                align: t.alignments.iter().copied().map(align_of).collect(),
310                rows,
311            }
312        }
313        // Everything else is either unreachable (`TableRow`/`TableCell`,
314        // handled inside `table_row` rather than through this generic walk)
315        // or an extension `to_nodes` never turns on (footnotes, math,
316        // wikilinks, alerts, ...), so it cannot appear in a tree this module
317        // produced.
318        _ => return None,
319    })
320}
321
322fn table_row<'a>(
323    row: &'a AstNode<'a>,
324    aligns: &[TableAlignment],
325    image_base: &ImageBase,
326) -> Vec<TableCell> {
327    let header = matches!(row.data.borrow().value, NodeValue::TableRow(true));
328    row.children()
329        .enumerate()
330        .map(|(i, cell)| TableCell {
331            header,
332            align: aligns.get(i).copied().map(align_of).unwrap_or(Align::None),
333            children: children_of(cell, image_base),
334        })
335        .collect()
336}
337
338/// The plain-text reading of a node tree: every [`Node::Text`]/[`Node::Code`]
339/// leaf's characters, recursively, with breaks turned into whitespace.
340///
341/// Used for an image's alt text (comrak keeps it as the image's inline
342/// children rather than a separate string) and for the label of a link whose
343/// scheme [`normalize_link`] refuses to keep live.
344fn plain_text(nodes: &[Node]) -> String {
345    let mut out = String::new();
346    for node in nodes {
347        match node {
348            Node::Text { value } | Node::Code { code: value } => out.push_str(value),
349            Node::Image { alt, .. } => out.push_str(alt),
350            Node::SoftBreak => out.push(' '),
351            Node::LineBreak => out.push('\n'),
352            Node::Paragraph { children }
353            | Node::Heading { children, .. }
354            | Node::Emphasis { children }
355            | Node::Strong { children }
356            | Node::Strikethrough { children }
357            | Node::BlockQuote { children }
358            | Node::ListItem { children, .. }
359            | Node::Link { children, .. } => out.push_str(&plain_text(children)),
360            Node::BulletList { .. }
361            | Node::OrderedList { .. }
362            | Node::Table { .. }
363            | Node::ThematicBreak
364            | Node::CodeBlock { .. } => {}
365        }
366    }
367    out
368}
369
370/// Keep a link live only for `http:`/`https:`; everything else — a
371/// `javascript:`/`data:`/`vbscript:`/`file:` scheme, or no scheme at all —
372/// is not a destination magi's web UI will navigate to, so the link
373/// disappears and its label survives as plain text.
374fn normalize_link(url: &str, children: Vec<Node>) -> Node {
375    let allowed = match url.split_once(':') {
376        Some((scheme, _)) => {
377            scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
378        }
379        None => false,
380    };
381    if allowed {
382        Node::Link {
383            href: url.to_owned(),
384            children,
385        }
386    } else {
387        Node::Text {
388            value: plain_text(&children),
389        }
390    }
391}
392
393/// Keep an image live only for a `data:` image URI, or — when `image_base`
394/// names a question — a bare relative filename resolved against that
395/// question's own panel asset route. An absolute `https://` image, a
396/// protocol-relative `//` image, and a relative path outside a question's
397/// panel all fail both checks and fall back to alt text plus the URL, so the
398/// operator still sees what the agent meant to show without magi's UI
399/// fetching anything from outside itself.
400fn normalize_image(url: &str, alt: String, image_base: &ImageBase) -> Node {
401    if url.to_ascii_lowercase().starts_with("data:image/") {
402        return Node::Image {
403            src: url.to_owned(),
404            alt,
405        };
406    }
407    if let ImageBase::QuestionPanel { id } = image_base {
408        if valid_asset_name(url) {
409            return Node::Image {
410                src: format!("/api/questions/{id}/panel/{url}"),
411                alt,
412            };
413        }
414    }
415    let value = if alt.is_empty() {
416        url.to_owned()
417    } else {
418        format!("{alt} ({url})")
419    };
420    Node::Text { value }
421}
422
423#[cfg(test)]
424mod tests {
425    use pretty_assertions::assert_eq;
426
427    use super::*;
428
429    fn nodes(text: &str) -> Vec<Node> {
430        to_nodes(text, &ImageBase::None)
431    }
432
433    fn text(s: &str) -> Node {
434        Node::Text {
435            value: s.to_owned(),
436        }
437    }
438
439    #[test]
440    fn a_heading_carries_its_level() {
441        assert_eq!(
442            nodes("### Three"),
443            vec![Node::Heading {
444                level: 3,
445                children: vec![text("Three")],
446            }]
447        );
448    }
449
450    #[test]
451    fn emphasis_and_strong_and_strikethrough_each_get_their_own_node() {
452        assert_eq!(
453            nodes("*i* **b** ~~s~~"),
454            vec![Node::Paragraph {
455                children: vec![
456                    Node::Emphasis {
457                        children: vec![text("i")]
458                    },
459                    text(" "),
460                    Node::Strong {
461                        children: vec![text("b")]
462                    },
463                    text(" "),
464                    Node::Strikethrough {
465                        children: vec![text("s")]
466                    },
467                ],
468            }]
469        );
470    }
471
472    #[test]
473    fn a_bullet_list_is_a_bullet_list() {
474        assert_eq!(
475            nodes("- one\n- two"),
476            vec![Node::BulletList {
477                items: vec![
478                    Node::ListItem {
479                        checked: None,
480                        children: vec![Node::Paragraph {
481                            children: vec![text("one")]
482                        }],
483                    },
484                    Node::ListItem {
485                        checked: None,
486                        children: vec![Node::Paragraph {
487                            children: vec![text("two")]
488                        }],
489                    },
490                ],
491            }]
492        );
493    }
494
495    #[test]
496    fn an_ordered_list_keeps_its_start_number() {
497        let Some(Node::OrderedList { start, items }) = nodes("5. five\n6. six").into_iter().next()
498        else {
499            panic!("expected an ordered list");
500        };
501        assert_eq!(start, 5);
502        assert_eq!(items.len(), 2);
503    }
504
505    #[test]
506    fn a_nested_list_is_a_list_item_containing_a_list() {
507        let doc = nodes("- outer\n  - inner");
508        let Some(Node::BulletList { items }) = doc.into_iter().next() else {
509            panic!("expected a bullet list");
510        };
511        let Node::ListItem { children, .. } = &items[0] else {
512            panic!("expected a list item");
513        };
514        assert!(
515            children
516                .iter()
517                .any(|c| matches!(c, Node::BulletList { .. })),
518            "the outer item's children should hold the nested list: {children:?}"
519        );
520    }
521
522    #[test]
523    fn task_list_items_carry_their_checked_state() {
524        let Some(Node::BulletList { items }) = nodes("- [ ] todo\n- [x] done").into_iter().next()
525        else {
526            panic!("expected a bullet list");
527        };
528        assert_eq!(items.len(), 2);
529        assert!(matches!(
530            items[0],
531            Node::ListItem {
532                checked: Some(false),
533                ..
534            }
535        ));
536        assert!(matches!(
537            items[1],
538            Node::ListItem {
539                checked: Some(true),
540                ..
541            }
542        ));
543    }
544
545    #[test]
546    fn a_table_keeps_its_header_and_its_column_alignment() {
547        let md = "| a | b |\n|:--|--:|\n| 1 | 2 |\n";
548        let Some(Node::Table { align, rows }) = nodes(md).into_iter().next() else {
549            panic!("expected a table");
550        };
551        assert_eq!(align, vec![Align::Left, Align::Right]);
552        assert_eq!(rows.len(), 2, "a header row and one body row: {rows:?}");
553        assert!(rows[0][0].header, "the first row is the header: {rows:?}");
554        assert!(!rows[1][0].header, "the body row is not a header: {rows:?}");
555        assert_eq!(rows[0][0].align, Align::Left);
556        assert_eq!(rows[0][1].align, Align::Right);
557    }
558
559    #[test]
560    fn a_block_quote_is_a_block_quote() {
561        assert_eq!(
562            nodes("> quoted"),
563            vec![Node::BlockQuote {
564                children: vec![Node::Paragraph {
565                    children: vec![text("quoted")]
566                }],
567            }]
568        );
569    }
570
571    #[test]
572    fn a_thematic_break_needs_nothing_else() {
573        assert_eq!(nodes("---"), vec![Node::ThematicBreak]);
574    }
575
576    #[test]
577    fn inline_code_is_never_interpreted_as_markdown() {
578        assert_eq!(
579            nodes("`*not italic*`"),
580            vec![Node::Paragraph {
581                children: vec![Node::Code {
582                    code: "*not italic*".to_owned()
583                }],
584            }]
585        );
586    }
587
588    #[test]
589    fn a_fenced_code_block_carries_its_language_but_no_color() {
590        assert_eq!(
591            nodes("```rust\nfn x() {}\n```"),
592            vec![Node::CodeBlock {
593                lang: Some("rust".to_owned()),
594                code: "fn x() {}\n".to_owned(),
595            }]
596        );
597    }
598
599    #[test]
600    fn an_http_link_stays_a_link() {
601        assert_eq!(
602            nodes("[go](https://example.com/x)"),
603            vec![Node::Paragraph {
604                children: vec![Node::Link {
605                    href: "https://example.com/x".to_owned(),
606                    children: vec![text("go")],
607                }],
608            }]
609        );
610    }
611
612    #[test]
613    fn a_javascript_link_is_not_a_link_node_at_all() {
614        let doc = nodes("[x](javascript:alert(1))");
615        // No node anywhere in the tree may be `Node::Link`.
616        fn has_link(nodes: &[Node]) -> bool {
617            nodes.iter().any(|n| match n {
618                Node::Link { .. } => true,
619                Node::Paragraph { children }
620                | Node::Heading { children, .. }
621                | Node::Emphasis { children }
622                | Node::Strong { children }
623                | Node::Strikethrough { children }
624                | Node::BlockQuote { children }
625                | Node::ListItem { children, .. } => has_link(children),
626                _ => false,
627            })
628        }
629        assert!(!has_link(&doc), "must not contain a link node: {doc:?}");
630        assert_eq!(
631            doc,
632            vec![Node::Paragraph {
633                children: vec![text("x")]
634            }]
635        );
636    }
637
638    #[test]
639    fn an_absolute_https_image_does_not_render() {
640        let doc = nodes("![a](https://example.com/x.png)");
641        assert_eq!(
642            doc,
643            vec![Node::Paragraph {
644                children: vec![text("a (https://example.com/x.png)")]
645            }]
646        );
647    }
648
649    #[test]
650    fn a_data_uri_image_renders() {
651        let doc = nodes("![a](data:image/png;base64,AAAA)");
652        assert_eq!(
653            doc,
654            vec![Node::Paragraph {
655                children: vec![Node::Image {
656                    src: "data:image/png;base64,AAAA".to_owned(),
657                    alt: "a".to_owned(),
658                }],
659            }]
660        );
661    }
662
663    #[test]
664    fn a_question_relative_image_resolves_to_its_panel_route() {
665        let base = ImageBase::QuestionPanel {
666            id: "20260903-014455-ab12".to_owned(),
667        };
668        let doc = to_nodes("![shot](shot.png)", &base);
669        assert_eq!(
670            doc,
671            vec![Node::Paragraph {
672                children: vec![Node::Image {
673                    src: "/api/questions/20260903-014455-ab12/panel/shot.png".to_owned(),
674                    alt: "shot".to_owned(),
675                }],
676            }]
677        );
678    }
679
680    #[test]
681    fn a_protocol_relative_image_does_not_render_even_with_a_question_base() {
682        let base = ImageBase::QuestionPanel {
683            id: "20260903-014455-ab12".to_owned(),
684        };
685        let doc = to_nodes("![a](//evil.example/x.png)", &base);
686        assert!(
687            !doc.iter().any(|n| matches!(n, Node::Paragraph { children } if children.iter().any(|c| matches!(c, Node::Image { .. })))),
688            "a protocol-relative source must never become an image: {doc:?}"
689        );
690    }
691
692    #[test]
693    fn raw_html_becomes_text_everywhere_in_the_tree() {
694        let doc = nodes("before <script>alert(1)</script> after");
695        fn contains_html_markup(nodes: &[Node]) -> bool {
696            nodes.iter().any(|n| match n {
697                Node::Text { value } => value.contains("<script"),
698                Node::Paragraph { children }
699                | Node::Heading { children, .. }
700                | Node::Emphasis { children }
701                | Node::Strong { children }
702                | Node::Strikethrough { children }
703                | Node::BlockQuote { children }
704                | Node::ListItem { children, .. } => contains_html_markup(children),
705                _ => false,
706            })
707        }
708        assert!(
709            contains_html_markup(&doc),
710            "the literal tag text must survive as a text node: {doc:?}"
711        );
712        // And, symmetrically: no node in the tree may claim to *be* HTML —
713        // there is no such variant, so this is really asserting the parse
714        // produced ordinary text/paragraph nodes and nothing else.
715        for node in &doc {
716            assert!(
717                matches!(node, Node::Paragraph { .. }),
718                "a document with only text and an HTML span is one paragraph: {doc:?}"
719            );
720        }
721    }
722
723    #[test]
724    fn a_block_level_script_tag_becomes_a_text_node_too() {
725        let doc = nodes("<script>alert(1)</script>");
726        assert_eq!(
727            doc,
728            vec![text("<script>alert(1)</script>")],
729            "an HTML block is one literal text node, not markup: {doc:?}"
730        );
731    }
732}