Skip to main content

solid_pod_rs_forge/
markdown.rs

1//! A small, bounded, escape-first Markdown renderer.
2//!
3//! Behaviour follows the JSS `forge` `lib/markdown.js` (`renderMarkdown`)
4//! grammar, cited by name only; the code is original and uses no regex
5//! engine. The design is structurally XSS-safe: the raw source is
6//! HTML-escaped with [`crate::request::esc`] *before* any tag is introduced,
7//! there is no HTML passthrough, and link / image targets are screened by
8//! [`safe_href`] (no `javascript:`, `data:`, protocol-relative or traversing
9//! targets).
10//!
11//! Blocks: `#`..`######` headings, fenced code, `>` quotes, `-`/`*` and `1.`
12//! lists, GFM tables with per-column alignment, paragraphs. Inline: code
13//! spans, `**strong**`, `*em*` / `_em_`, `[text](href)` and `![alt](src)`.
14//! No nesting.
15//!
16//! # Examples
17//!
18//! ```
19//! use solid_pod_rs_forge::markdown::render;
20//!
21//! let html = render("# Hi\n\nsome **bold** and `code`\n\n<script>x</script>");
22//! assert!(html.contains("<h1>Hi</h1>"));
23//! assert!(html.contains("<strong>bold</strong>"));
24//! assert!(html.contains("&lt;script&gt;"));
25//! assert!(!html.contains("<script>"));
26//! ```
27
28use crate::request::esc;
29
30/// Columns rendered per table (defence in depth).
31const TABLE_COL_CAP: usize = 100;
32/// Body rows rendered per table.
33const TABLE_ROW_CAP: usize = 1000;
34
35/// Link bases for relative targets: images resolve against `raw_base`, links
36/// against `blob_base` (JSS passes `…/raw/<ref>` and `…/blob/<ref>`).
37#[derive(Debug, Clone, Default)]
38pub struct MdCtx {
39    /// Prefix for relative image sources.
40    pub raw_base: String,
41    /// Prefix for relative link targets.
42    pub blob_base: String,
43}
44
45/// Render `src` with empty link bases.
46///
47/// # Examples
48///
49/// ```
50/// assert!(solid_pod_rs_forge::markdown::render("- a\n- b").contains("<ul><li>a</li><li>b</li></ul>"));
51/// ```
52#[must_use]
53pub fn render(src: &str) -> String {
54    render_with(src, &MdCtx::default())
55}
56
57/// Screen a link / image target. Absolute `http(s)` and `#fragment` targets
58/// pass; protocol-relative and any other scheme are refused; a relative path
59/// with an empty or `..` segment (or a leading `/`) is refused, otherwise it
60/// is joined onto `base`.
61///
62/// # Examples
63///
64/// ```
65/// use solid_pod_rs_forge::markdown::safe_href;
66/// assert_eq!(safe_href("https://x.test/a", "/b").as_deref(), Some("https://x.test/a"));
67/// assert_eq!(safe_href("docs/n.txt", "/blob").as_deref(), Some("/blob/docs/n.txt"));
68/// assert_eq!(safe_href("javascript:alert(1)", "/b"), None);
69/// assert_eq!(safe_href("../x", "/b"), None);
70/// assert_eq!(safe_href("//evil.test/x", "/b"), None);
71/// ```
72#[must_use]
73pub fn safe_href(url: &str, base: &str) -> Option<String> {
74    let lower = url.to_ascii_lowercase();
75    if lower.starts_with("http://") || lower.starts_with("https://") || url.starts_with('#') {
76        return Some(url.to_string());
77    }
78    if url.starts_with("//") {
79        return None;
80    }
81    if has_scheme(url) {
82        return None;
83    }
84    let rel = url.strip_prefix("./").unwrap_or(url);
85    if rel.starts_with('/') || rel.split('/').any(|s| s == ".." || s.is_empty()) {
86        return None;
87    }
88    Some(format!("{base}/{rel}"))
89}
90
91/// `^[a-z][a-z0-9+.-]*:`
92fn has_scheme(url: &str) -> bool {
93    let mut it = url.chars();
94    match it.next() {
95        Some(c) if c.is_ascii_alphabetic() => {}
96        _ => return false,
97    }
98    for c in it {
99        if c == ':' {
100            return true;
101        }
102        if !(c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')) {
103            return false;
104        }
105    }
106    false
107}
108
109fn code_spans(s: &str) -> (String, Vec<String>) {
110    let c: Vec<char> = s.chars().collect();
111    let mut out = String::new();
112    let mut codes = Vec::new();
113    let mut i = 0;
114    while i < c.len() {
115        if c[i] == '`' {
116            if let Some(rel) = c[i + 1..].iter().position(|&x| x == '`') {
117                if rel > 0 {
118                    codes.push(c[i + 1..i + 1 + rel].iter().collect::<String>());
119                    out.push_str(&format!("\u{1}{}\u{1}", codes.len() - 1));
120                    i += rel + 2;
121                    continue;
122                }
123            }
124        }
125        out.push(c[i]);
126        i += 1;
127    }
128    (out, codes)
129}
130
131/// `[text](url)` / `![alt](url)` with `text` free of `]` and `url` free of
132/// whitespace and `)`. Unsafe targets leave the source text unchanged.
133fn bracket_pass(s: &str, image: bool, base: &str) -> String {
134    let c: Vec<char> = s.chars().collect();
135    let mut out = String::new();
136    let mut i = 0;
137    while i < c.len() {
138        let start = if image {
139            (c[i] == '!' && c.get(i + 1) == Some(&'[')).then_some(i + 2)
140        } else {
141            (c[i] == '[').then_some(i + 1)
142        };
143        if let Some(ts) = start {
144            if let Some(rel) = c[ts..].iter().position(|&x| x == ']') {
145                let te = ts + rel;
146                let text_ok = image || te > ts;
147                if text_ok && c.get(te + 1) == Some(&'(') {
148                    let us = te + 2;
149                    let mut ue = us;
150                    while ue < c.len() && c[ue] != ')' && !c[ue].is_whitespace() {
151                        ue += 1;
152                    }
153                    if ue > us && c.get(ue) == Some(&')') {
154                        let text: String = c[ts..te].iter().collect();
155                        let url: String = c[us..ue].iter().collect();
156                        if let Some(h) = safe_href(&url, base) {
157                            if image {
158                                out.push_str(&format!("<img src=\"{h}\" alt=\"{text}\">"));
159                            } else {
160                                out.push_str(&format!("<a href=\"{h}\">{text}</a>"));
161                            }
162                        } else {
163                            out.extend(&c[i..=ue]);
164                        }
165                        i = ue + 1;
166                        continue;
167                    }
168                }
169            }
170        }
171        out.push(c[i]);
172        i += 1;
173    }
174    out
175}
176
177fn strong_pass(s: &str) -> String {
178    let c: Vec<char> = s.chars().collect();
179    let mut out = String::new();
180    let mut i = 0;
181    while i < c.len() {
182        if c[i] == '*' && c.get(i + 1) == Some(&'*') {
183            let mut j = i + 2;
184            while j < c.len() && c[j] != '*' {
185                j += 1;
186            }
187            if j > i + 2 && c.get(j) == Some(&'*') && c.get(j + 1) == Some(&'*') {
188                out.push_str("<strong>");
189                out.extend(&c[i + 2..j]);
190                out.push_str("</strong>");
191                i = j + 2;
192                continue;
193            }
194        }
195        out.push(c[i]);
196        i += 1;
197    }
198    out
199}
200
201fn em_star_pass(s: &str) -> String {
202    let c: Vec<char> = s.chars().collect();
203    let mut out = String::new();
204    let mut i = 0;
205    while i < c.len() {
206        if c[i] == '*' {
207            if let Some(&f) = c.get(i + 1) {
208                if f != '*' && !f.is_whitespace() {
209                    let mut j = i + 2;
210                    while j < c.len() && c[j] != '*' {
211                        j += 1;
212                    }
213                    if j < c.len() {
214                        out.push_str("<em>");
215                        out.extend(&c[i + 1..j]);
216                        out.push_str("</em>");
217                        i = j + 1;
218                        continue;
219                    }
220                }
221            }
222        }
223        out.push(c[i]);
224        i += 1;
225    }
226    out
227}
228
229fn em_underscore_pass(s: &str) -> String {
230    let c: Vec<char> = s.chars().collect();
231    let mut out = String::new();
232    let mut i = 0;
233    while i < c.len() {
234        let boundary = i == 0 || c[i - 1].is_whitespace();
235        if c[i] == '_' && boundary {
236            if let Some(rel) = c[i + 1..].iter().position(|&x| x == '_') {
237                let j = i + 1 + rel;
238                let followed = c.get(j + 1).is_none_or(|n| n.is_whitespace());
239                if rel > 0 && followed {
240                    out.push_str("<em>");
241                    out.extend(&c[i + 1..j]);
242                    out.push_str("</em>");
243                    i = j + 1;
244                    continue;
245                }
246            }
247        }
248        out.push(c[i]);
249        i += 1;
250    }
251    out
252}
253
254fn inline(escaped: &str, ctx: &MdCtx) -> String {
255    let (s, codes) = code_spans(escaped);
256    let s = bracket_pass(&s, true, &ctx.raw_base);
257    let s = bracket_pass(&s, false, &ctx.blob_base);
258    let s = strong_pass(&s);
259    let s = em_star_pass(&s);
260    let s = em_underscore_pass(&s);
261    // Restore code spans.
262    let c: Vec<char> = s.chars().collect();
263    let mut out = String::new();
264    let mut i = 0;
265    while i < c.len() {
266        if c[i] == '\u{1}' {
267            if let Some(rel) = c[i + 1..].iter().position(|&x| x == '\u{1}') {
268                let digits: String = c[i + 1..i + 1 + rel].iter().collect();
269                if let Ok(n) = digits.parse::<usize>() {
270                    out.push_str("<code>");
271                    out.push_str(codes.get(n).map_or("", String::as_str));
272                    out.push_str("</code>");
273                    i += rel + 2;
274                    continue;
275                }
276            }
277        }
278        out.push(c[i]);
279        i += 1;
280    }
281    out
282}
283
284fn split_row(line: &str) -> Vec<String> {
285    let mut t = line.trim();
286    t = t.strip_prefix('|').unwrap_or(t);
287    let t = t.trim_end();
288    let t = t.strip_suffix('|').unwrap_or(t);
289    let mut cells = Vec::new();
290    let mut cur = String::new();
291    let mut prev_backslash = false;
292    for ch in t.chars() {
293        if ch == '|' && !prev_backslash {
294            cells.push(cur.trim().replace("\\|", "|"));
295            cur.clear();
296        } else {
297            cur.push(ch);
298        }
299        prev_backslash = ch == '\\';
300    }
301    cells.push(cur.trim().replace("\\|", "|"));
302    cells
303}
304
305fn is_table_delim(line: &str) -> bool {
306    let cells = split_row(line);
307    !cells.is_empty()
308        && cells.iter().all(|c| {
309            let core = c.strip_prefix(':').unwrap_or(c);
310            let core = core.strip_suffix(':').unwrap_or(core);
311            !core.is_empty() && core.bytes().all(|b| b == b'-')
312        })
313}
314
315fn align_of(cell: &str) -> &'static str {
316    match (cell.starts_with(':'), cell.ends_with(':')) {
317        (true, true) => "center",
318        (false, true) => "right",
319        (true, false) => "left",
320        _ => "",
321    }
322}
323
324/// Render `src` to HTML with link bases from `ctx`.
325///
326/// # Examples
327///
328/// ```
329/// use solid_pod_rs_forge::markdown::{render_with, MdCtx};
330/// let ctx = MdCtx { raw_base: "/raw".into(), blob_base: "/blob".into() };
331/// let html = render_with("[doc](docs/notes.txt)", &ctx);
332/// assert!(html.contains("<a href=\"/blob/docs/notes.txt\">doc</a>"));
333/// ```
334#[must_use]
335pub fn render_with(src: &str, ctx: &MdCtx) -> String {
336    let cleaned: String = src.chars().filter(|&c| c != '\0' && c != '\u{1}').collect();
337    let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "\n");
338    let lines: Vec<&str> = cleaned.split('\n').collect();
339    let mut out: Vec<String> = Vec::new();
340    let mut para: Vec<String> = Vec::new();
341    let mut list: Option<(&str, Vec<String>)> = None;
342    let mut quote: Vec<String> = Vec::new();
343
344    fn flush_para(out: &mut Vec<String>, para: &mut Vec<String>, ctx: &MdCtx) {
345        if !para.is_empty() {
346            out.push(format!("<p>{}</p>", inline(&esc(&para.join(" ")), ctx)));
347            para.clear();
348        }
349    }
350    fn flush_list(out: &mut Vec<String>, list: &mut Option<(&str, Vec<String>)>) {
351        if let Some((tag, items)) = list.take() {
352            let lis: String = items.iter().map(|i| format!("<li>{i}</li>")).collect();
353            out.push(format!("<{tag}>{lis}</{tag}>"));
354        }
355    }
356    fn flush_quote(out: &mut Vec<String>, quote: &mut Vec<String>, ctx: &MdCtx) {
357        if !quote.is_empty() {
358            out.push(format!(
359                "<blockquote><p>{}</p></blockquote>",
360                inline(&esc(&quote.join(" ")), ctx)
361            ));
362            quote.clear();
363        }
364    }
365
366    let mut i = 0;
367    while i < lines.len() {
368        let line = lines[i];
369        if line.starts_with("```") {
370            flush_para(&mut out, &mut para, ctx);
371            flush_list(&mut out, &mut list);
372            flush_quote(&mut out, &mut quote, ctx);
373            let mut code = Vec::new();
374            i += 1;
375            while i < lines.len() && !lines[i].starts_with("```") {
376                code.push(lines[i]);
377                i += 1;
378            }
379            out.push(format!("<pre><code>{}</code></pre>", esc(&code.join("\n"))));
380            i += 1;
381            continue;
382        }
383        if line.contains('|') && i + 1 < lines.len() && is_table_delim(lines[i + 1]) {
384            flush_para(&mut out, &mut para, ctx);
385            flush_list(&mut out, &mut list);
386            flush_quote(&mut out, &mut quote, ctx);
387            let header: Vec<String> = split_row(line).into_iter().take(TABLE_COL_CAP).collect();
388            let aligns: Vec<&str> = split_row(lines[i + 1])
389                .iter()
390                .map(|c| align_of(c))
391                .collect();
392            let mut rows = Vec::new();
393            let mut j = i + 2;
394            while j < lines.len()
395                && lines[j].contains('|')
396                && !lines[j].trim().is_empty()
397                && rows.len() < TABLE_ROW_CAP
398            {
399                rows.push(split_row(lines[j]));
400                j += 1;
401            }
402            let cell = |txt: &str, tag: &str, al: &str| {
403                let style = if al.is_empty() {
404                    String::new()
405                } else {
406                    format!(" style=\"text-align:{al}\"")
407                };
408                format!("<{tag}{style}>{}</{tag}>", inline(&esc(txt), ctx))
409            };
410            let al = |k: usize| aligns.get(k).copied().unwrap_or("");
411            let thead: String = header
412                .iter()
413                .enumerate()
414                .map(|(k, c)| cell(c, "th", al(k)))
415                .collect();
416            let tbody: String = rows
417                .iter()
418                .map(|r| {
419                    let tds: String = (0..header.len())
420                        .map(|k| cell(r.get(k).map_or("", String::as_str), "td", al(k)))
421                        .collect();
422                    format!("<tr>{tds}</tr>")
423                })
424                .collect();
425            out.push(format!(
426                "<table><thead><tr>{thead}</tr></thead><tbody>{tbody}</tbody></table>"
427            ));
428            i = j;
429            continue;
430        }
431        if let Some((level, text)) = heading(line) {
432            flush_para(&mut out, &mut para, ctx);
433            flush_list(&mut out, &mut list);
434            flush_quote(&mut out, &mut quote, ctx);
435            out.push(format!(
436                "<h{level}>{}</h{level}>",
437                inline(&esc(text.trim()), ctx)
438            ));
439            i += 1;
440            continue;
441        }
442        if let Some(rest) = line.strip_prefix('>') {
443            flush_para(&mut out, &mut para, ctx);
444            flush_list(&mut out, &mut list);
445            quote.push(rest.strip_prefix(' ').unwrap_or(rest).to_string());
446            i += 1;
447            continue;
448        }
449        if let Some(item) = bullet(line) {
450            flush_para(&mut out, &mut para, ctx);
451            flush_quote(&mut out, &mut quote, ctx);
452            if list.as_ref().is_none_or(|(t, _)| *t != "ul") {
453                flush_list(&mut out, &mut list);
454                list = Some(("ul", Vec::new()));
455            }
456            if let Some((_, items)) = list.as_mut() {
457                items.push(inline(&esc(item), ctx));
458            }
459            i += 1;
460            continue;
461        }
462        if let Some(item) = numbered(line) {
463            flush_para(&mut out, &mut para, ctx);
464            flush_quote(&mut out, &mut quote, ctx);
465            if list.as_ref().is_none_or(|(t, _)| *t != "ol") {
466                flush_list(&mut out, &mut list);
467                list = Some(("ol", Vec::new()));
468            }
469            if let Some((_, items)) = list.as_mut() {
470                items.push(inline(&esc(item), ctx));
471            }
472            i += 1;
473            continue;
474        }
475        if line.trim().is_empty() {
476            flush_para(&mut out, &mut para, ctx);
477            flush_list(&mut out, &mut list);
478            flush_quote(&mut out, &mut quote, ctx);
479            i += 1;
480            continue;
481        }
482        flush_list(&mut out, &mut list);
483        flush_quote(&mut out, &mut quote, ctx);
484        para.push(line.trim().to_string());
485        i += 1;
486    }
487    flush_para(&mut out, &mut para, ctx);
488    flush_list(&mut out, &mut list);
489    flush_quote(&mut out, &mut quote, ctx);
490    out.join("\n")
491}
492
493fn heading(line: &str) -> Option<(usize, &str)> {
494    let n = line.bytes().take_while(|&b| b == b'#').count();
495    if !(1..=6).contains(&n) {
496        return None;
497    }
498    let rest = &line[n..];
499    let first = rest.chars().next()?;
500    first.is_whitespace().then(|| (n, rest.trim_start()))
501}
502
503fn bullet(line: &str) -> Option<&str> {
504    let t = line.trim_start();
505    let rest = t.strip_prefix('-').or_else(|| t.strip_prefix('*'))?;
506    let first = rest.chars().next()?;
507    first.is_whitespace().then(|| rest.trim_start())
508}
509
510fn numbered(line: &str) -> Option<&str> {
511    let t = line.trim_start();
512    let digits = t.bytes().take_while(u8::is_ascii_digit).count();
513    if digits == 0 {
514        return None;
515    }
516    let rest = t[digits..].strip_prefix('.')?;
517    let first = rest.chars().next()?;
518    first.is_whitespace().then(|| rest.trim_start())
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    fn ctx() -> MdCtx {
526        MdCtx {
527            raw_base: "/raw".into(),
528            blob_base: "/blob".into(),
529        }
530    }
531
532    // JSS test.js "renders a table with header row, per-column alignment ..."
533    #[test]
534    fn table_with_alignment_and_inline_cells() {
535        let md = "| Name | Score |\n| :--- | ---: |\n| **a** | `9` |\n| b | 10 |";
536        let html = render_with(md, &ctx());
537        assert!(html.contains("<table><thead>"));
538        assert!(html.contains("<th style=\"text-align:left\">Name</th>"));
539        assert!(html.contains("<th style=\"text-align:right\">Score</th>"));
540        assert!(html.contains("<td style=\"text-align:left\"><strong>a</strong></td>"));
541        assert!(html.contains("<td style=\"text-align:right\"><code>9</code></td>"));
542        assert!(html.contains("<td style=\"text-align:right\">10</td>"));
543        assert!(!html.contains("<p>| Name"));
544    }
545
546    // JSS "is escape-first: HTML in a cell is neutralized"
547    #[test]
548    fn table_cells_are_escaped() {
549        let html = render_with("| x |\n| - |\n| <img src=x onerror=alert(1)> |", &ctx());
550        assert!(html.contains("&lt;img src=x"));
551        assert!(!html.contains("<img src=x"));
552    }
553
554    // JSS "honors escaped pipes inside cells"
555    #[test]
556    fn escaped_pipes_in_cells() {
557        let html = render_with("| a | b |\n| - | - |\n| x \\| y | z |", &ctx());
558        assert!(html.contains("<td>x | y</td>"), "{html}");
559    }
560
561    #[test]
562    fn readme_fixture_renders_heading_code_and_escapes_script() {
563        let md = "# Demo Project\n\nA demo with **bold**, `inline code`, and a [doc](docs/notes.txt).\n\n```js\nconsole.log('hi');\n```\n\n<script>alert(1)</script>\n";
564        let html = render_with(md, &ctx());
565        assert!(html.contains("<h1>Demo Project</h1>"));
566        assert!(html.contains("<strong>bold</strong>"));
567        assert!(html.contains("<code>inline code</code>"));
568        assert!(html.contains("<a href=\"/blob/docs/notes.txt\">doc</a>"));
569        assert!(html.contains("<pre><code>console.log(&#x27;hi&#x27;);</code></pre>"));
570        assert!(!html.contains("<script>alert"));
571        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
572    }
573
574    #[test]
575    fn hostile_targets_are_refused() {
576        let html = render_with("[x](javascript:alert(1)) ![y](data:text/html,a)", &ctx());
577        assert!(!html.contains("<a href"));
578        assert!(!html.contains("<img"));
579        let html = render_with("[x](../../etc/passwd) [z](//evil.test/x)", &ctx());
580        assert!(!html.contains("<a href"));
581    }
582
583    #[test]
584    fn lists_quotes_and_emphasis() {
585        assert_eq!(render("- a\n- b"), "<ul><li>a</li><li>b</li></ul>");
586        assert_eq!(render("1. a\n2. b"), "<ol><li>a</li><li>b</li></ol>");
587        assert_eq!(
588            render("> hi\n> there"),
589            "<blockquote><p>hi there</p></blockquote>"
590        );
591        assert_eq!(
592            render("*em* and _em2_ ok"),
593            "<p><em>em</em> and <em>em2</em> ok</p>"
594        );
595        assert_eq!(render("snake_case_name"), "<p>snake_case_name</p>");
596        assert_eq!(render("####### seven"), "<p>####### seven</p>");
597    }
598
599    #[test]
600    fn nul_and_control_markers_are_stripped() {
601        assert_eq!(render("a\u{0}b\u{1}c"), "<p>abc</p>");
602    }
603}