1use crate::request::esc;
29
30const TABLE_COL_CAP: usize = 100;
32const TABLE_ROW_CAP: usize = 1000;
34
35#[derive(Debug, Clone, Default)]
38pub struct MdCtx {
39 pub raw_base: String,
41 pub blob_base: String,
43}
44
45#[must_use]
53pub fn render(src: &str) -> String {
54 render_with(src, &MdCtx::default())
55}
56
57#[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
91fn 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
131fn 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 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#[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(¶.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("e.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 #[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 #[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("<img src=x"));
551 assert!(!html.contains("<img src=x"));
552 }
553
554 #[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('hi');</code></pre>"));
570 assert!(!html.contains("<script>alert"));
571 assert!(html.contains("<script>alert(1)</script>"));
572 }
573
574 #[test]
575 fn hostile_targets_are_refused() {
576 let html = render_with("[x](javascript:alert(1)) ", &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}