@jpgilldev / steid

25.5 KBRaw
1//! Rendering markdown, for READMEs and later for posts.
2//!
3//! **Raw HTML in the source is never passed through.** Today only a repository's owner
4//! can push to it, so a README is trusted content — but Milestone 7 adds other users,
5//! and markdown rendered with HTML passthrough on the same origin as the session
6//! cookie is stored XSS. Turning it off now costs nothing; retrofitting a sanitiser
7//! later is a security migration. Do not "improve" this back on.
8//!
9//! The guarantee is structural rather than a filter: this module walks the parser's
10//! event stream and writes the HTML itself, so the set of tags that can reach a page is
11//! exactly the set spelled out in [`Writer`]. A `<script>` in the source is not
12//! stripped — it arrives as [`Event::Html`] and is written as *text*, so a reader sees
13//! what was written and a browser sees nothing to execute.
14//!
15//! Writing the HTML by hand rather than calling `pulldown_cmark::html::push_html` is
16//! also forced: `pulldown-cmark` is depended on with `default-features = false`, which
17//! turns its `html` module off. Escaping is not hand-rolled — it goes through
18//! [`HtmlContext`], the same escaper the `view!` macro writes every dynamic value
19//! through.
20//!
21//! The output is [`Unescaped`], which is how it reaches a `view!`: everything in a view
22//! is escaped by default, and this is the deliberate hatch. That hatch is exactly why
23//! raw HTML in the source has to be off — the two decisions are one decision.
24
25use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
26use topcoat::view::{Formatter, HtmlContext, Unescaped};
27
28/// URL schemes a link or image may use.
29///
30/// An allowlist, not a `javascript:` denylist: a denylist has to anticipate every
31/// scripting scheme (`vbscript:`, `data:text/html`, whatever a browser adds next),
32/// while an allowlist only has to name the ones that are useful in prose.
33const ALLOWED_SCHEMES: &[&str] = &["http", "https", "mailto", "ftp", "ftps", "tel"];
34
35/// Renders markdown to HTML, leaving relative links exactly as written.
36pub fn render(source: &str) -> Unescaped<String> {
37 render_with_links(source, |_| None)
38}
39
40/// Renders markdown, giving `resolve` a chance to rewrite relative links.
41///
42/// `resolve` is called only for a destination that is relative — no scheme, and not
43/// starting with `/` or `#` — and returning `None` leaves it untouched. It is a
44/// parameter rather than something this module knows how to do because what a relative
45/// link means depends on where the markdown came from: inside a repository it is
46/// another file in the tree, and in a post it will be something else.
47///
48/// Resolution happens *before* the scheme check, so a resolver cannot hand back a
49/// `javascript:` URL and have it reach the page.
50pub fn render_with_links(
51 source: &str,
52 resolve: impl Fn(&str) -> Option<String>,
53) -> Unescaped<String> {
54 let mut buf = String::with_capacity(source.len());
55 let mut writer = Writer::new(&mut buf, resolve);
56
57 for event in Parser::new_ext(source, options()) {
58 writer.event(event);
59 }
60
61 Unescaped::new_unchecked(buf)
62}
63
64/// The dialect Steid understands.
65///
66/// Tables, strikethrough, task lists and footnotes are the parts of GitHub-flavoured
67/// markdown a README actually uses. Smart punctuation is deliberately *off*: it turns
68/// `--flag` into an en dash, which silently corrupts command-line flags written in
69/// prose — the exact thing a README is full of.
70fn options() -> Options {
71 Options::ENABLE_TABLES
72 | Options::ENABLE_STRIKETHROUGH
73 | Options::ENABLE_TASKLISTS
74 | Options::ENABLE_FOOTNOTES
75}
76
77/// What a link destination turned out to be.
78enum Destination {
79 /// Safe to put in an `href` or `src`.
80 Allowed(String),
81 /// A scheme that is not in [`ALLOWED_SCHEMES`]. The attribute is dropped entirely.
82 Refused,
83}
84
85/// Decides whether a destination may appear in an attribute, and in what form.
86///
87/// `resolve_relative` is off for an image: a relative image points at a file that has
88/// to be served as its own bytes, and rewriting it to a page URL would produce a
89/// broken image rather than a working one.
90fn destination(
91 raw: &str,
92 resolve_relative: bool,
93 resolve: &impl Fn(&str) -> Option<String>,
94) -> Destination {
95 // Browsers strip ASCII control characters — tab, newline, carriage return among
96 // them — out of a URL before parsing it, so `java&Tab;script:alert(1)` is a
97 // `javascript:` URL by the time it matters. Judge the stripped form *and* emit it,
98 // or the check and the browser would be looking at different strings. Interior
99 // spaces are left alone: they are legal in a `<...>` destination and are not a way
100 // to hide a scheme, since a scheme cannot contain one.
101 let cleaned: String = raw
102 .trim()
103 .chars()
104 .filter(|c| !c.is_ascii_control())
105 .collect();
106
107 let resolved = match scheme_of(&cleaned) {
108 Some(_) => cleaned,
109 // No scheme. An absolute path and a fragment already point at this origin; only
110 // a genuinely relative destination is the caller's to reinterpret.
111 None if cleaned.starts_with('/') || cleaned.starts_with('#') || cleaned.is_empty() => {
112 cleaned
113 }
114 None if resolve_relative => resolve(&cleaned).unwrap_or(cleaned),
115 None => cleaned,
116 };
117
118 match scheme_of(&resolved) {
119 Some(scheme) if !ALLOWED_SCHEMES.contains(&scheme.as_str()) => Destination::Refused,
120 _ => Destination::Allowed(resolved),
121 }
122}
123
124/// The URL's scheme, lowercased, or `None` when it has none.
125///
126/// A colon only introduces a scheme when it comes before any `/`, `?` or `#` and what
127/// precedes it is a legal scheme name — otherwise `docs/a:b.md` and `#a:b` would read
128/// as schemes and be refused.
129fn scheme_of(url: &str) -> Option<String> {
130 let end = url.find([':', '/', '?', '#'])?;
131
132 if url.as_bytes()[end] != b':' {
133 return None;
134 }
135
136 let scheme = &url[..end];
137 let mut chars = scheme.chars();
138
139 if !chars.next()?.is_ascii_alphabetic() {
140 return None;
141 }
142 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
143 return None;
144 }
145
146 Some(scheme.to_ascii_lowercase())
147}
148
149/// An image being assembled.
150///
151/// An image's alt text arrives as the events *between* its start and end tags, so it
152/// has to be collected before the `<img>` can be written at all.
153struct Image {
154 destination: Destination,
155 title: String,
156 alt: String,
157}
158
159/// Writes an event stream out as HTML.
160///
161/// Every tag this can emit is written literally below, which is what makes "raw HTML
162/// cannot reach the page" a property of the code rather than of a filter that has to be
163/// kept correct. The classes are Tailwind utilities in theme tokens only — a hardcoded
164/// colour would follow neither a palette change nor the colour scheme — and they live
165/// here as literals so Tailwind's scan of `./src/**/*.rs` finds them.
166struct Writer<'a, F> {
167 out: Formatter<'a>,
168 resolve: F,
169 image: Option<Image>,
170 alignments: Vec<Alignment>,
171 column: usize,
172 in_head: bool,
173}
174
175impl<'a, F: Fn(&str) -> Option<String>> Writer<'a, F> {
176 fn new(buf: &'a mut String, resolve: F) -> Self {
177 Self {
178 out: Formatter::new(buf),
179 resolve,
180 image: None,
181 alignments: Vec::new(),
182 column: 0,
183 in_head: false,
184 }
185 }
186
187 /// Markup this module chose. Never anything derived from the source.
188 fn raw(&mut self, markup: &str) {
189 self.out.write_str(markup);
190 }
191
192 /// Content from the source, as a text node — or as alt text while inside an image.
193 fn text(&mut self, value: &str) {
194 if let Some(image) = self.image.as_mut() {
195 image.alt.push_str(value);
196 return;
197 }
198
199 HtmlContext::Text.writer(&mut self.out).write_str(value);
200 }
201
202 /// Content from the source, inside a double-quoted attribute value.
203 fn attribute(&mut self, name: &str, value: &str) {
204 self.out.write_str(" ");
205 self.out.write_str(name);
206 self.out.write_str("=\"");
207 HtmlContext::AttributeValue
208 .writer(&mut self.out)
209 .write_str(value);
210 self.out.write_str("\"");
211 }
212
213 fn event(&mut self, event: Event<'_>) {
214 match event {
215 Event::Start(tag) => self.start(tag),
216 Event::End(tag) => self.end(tag),
217 Event::Text(text) => self.text(&text),
218 Event::Code(code) => {
219 self.raw(
220 "<code class=\"rounded border border-border bg-surface px-1 py-0.5 font-mono text-[0.85em]\">",
221 );
222 self.text(&code);
223 self.raw("</code>");
224 }
225 // The security-critical case, and the reason this module exists in this
226 // shape. HTML in the source is written as text: visible, inert, and
227 // honest about what the author wrote.
228 Event::Html(html) | Event::InlineHtml(html) => self.text(&html),
229 Event::SoftBreak => self.raw("\n"),
230 Event::HardBreak => self.raw("<br />"),
231 Event::Rule => self.raw("<hr class=\"my-6 border-t border-border\" />"),
232 Event::FootnoteReference(label) => {
233 self.raw("<sup><a class=\"text-primary hover:underline\"");
234 self.attribute("href", &format!("#fn-{label}"));
235 self.raw(">");
236 self.text(&label);
237 self.raw("</a></sup>");
238 }
239 Event::TaskListMarker(checked) => {
240 self.raw("<input type=\"checkbox\" disabled class=\"mr-2 align-middle\"");
241 if checked {
242 self.raw(" checked");
243 }
244 self.raw(" />");
245 }
246 // Only reachable with options this module does not enable.
247 Event::InlineMath(value) | Event::DisplayMath(value) => self.text(&value),
248 }
249 }
250
251 fn start(&mut self, tag: Tag<'_>) {
252 match tag {
253 Tag::Paragraph => self.raw("<p class=\"my-3 leading-relaxed\">"),
254 Tag::Heading { level, .. } => self.raw(heading_open(level)),
255 Tag::BlockQuote(_) => self.raw(
256 "<blockquote class=\"my-4 border-l-2 border-border pl-4 text-muted-foreground\">",
257 ),
258 Tag::CodeBlock(_) => self.raw(
259 "<pre class=\"my-4 overflow-x-auto rounded-lg border border-border bg-surface px-4 py-3 font-mono text-xs leading-relaxed\"><code>",
260 ),
261 // The block itself is nothing; its lines arrive as `Event::Html` and are
262 // written as text, so they need somewhere to sit.
263 Tag::HtmlBlock => self.raw("<p class=\"my-3 leading-relaxed\">"),
264 Tag::List(None) => self.raw("<ul class=\"my-3 list-disc space-y-1 pl-6\">"),
265 Tag::List(Some(first)) => {
266 self.raw("<ol class=\"my-3 list-decimal space-y-1 pl-6\"");
267 if first != 1 {
268 self.attribute("start", &first.to_string());
269 }
270 self.raw(">");
271 }
272 Tag::Item => self.raw("<li>"),
273 Tag::FootnoteDefinition(label) => {
274 self.raw("<div class=\"mt-2 text-xs text-muted-foreground\"");
275 self.attribute("id", &format!("fn-{label}"));
276 self.raw("><span class=\"mr-2 font-mono\">");
277 self.text(&label);
278 self.raw("</span>");
279 }
280 Tag::Table(alignments) => {
281 self.alignments = alignments;
282 self.raw(
283 "<div class=\"my-4 overflow-x-auto\"><table class=\"w-full border-collapse text-sm\">",
284 );
285 }
286 Tag::TableHead => {
287 self.in_head = true;
288 self.column = 0;
289 self.raw("<thead><tr>");
290 }
291 Tag::TableRow => {
292 self.column = 0;
293 self.raw("<tr>");
294 }
295 Tag::TableCell => {
296 let alignment = self.alignments.get(self.column).copied();
297
298 if self.in_head {
299 // A header with no stated alignment is left-aligned rather than
300 // left to the browser, which centres `<th>` by default and makes a
301 // plain markdown table look deliberately centred when it is not.
302 self.raw("<th class=\"border-b border-border px-3 py-2 font-medium ");
303 self.raw(align_class(alignment).unwrap_or("text-left"));
304 } else {
305 self.raw("<td class=\"border-b border-border px-3 py-2 ");
306 self.raw(align_class(alignment).unwrap_or(""));
307 }
308
309 self.raw("\">");
310 }
311 Tag::Emphasis => self.raw("<em>"),
312 Tag::Strong => self.raw("<strong class=\"font-semibold\">"),
313 Tag::Strikethrough => self.raw("<del>"),
314 Tag::Superscript => self.raw("<sup>"),
315 Tag::Subscript => self.raw("<sub>"),
316 Tag::Link {
317 dest_url, title, ..
318 } => {
319 self.raw("<a");
320
321 // A refused destination keeps its text but loses its `href` — and its
322 // link styling with it, so it does not read as a link that silently
323 // does nothing. Dropping the element entirely would swallow what the
324 // author wrote.
325 if let Destination::Allowed(url) = destination(&dest_url, true, &self.resolve) {
326 self.raw(" class=\"text-primary hover:underline\"");
327 self.attribute("href", &url);
328 }
329 if !title.is_empty() {
330 self.attribute("title", &title);
331 }
332
333 self.raw(">");
334 }
335 Tag::Image {
336 dest_url, title, ..
337 } => {
338 self.image = Some(Image {
339 destination: destination(&dest_url, false, &self.resolve),
340 title: title.into_string(),
341 alt: String::new(),
342 });
343 }
344 // Only reachable with options this module does not enable.
345 Tag::DefinitionList
346 | Tag::DefinitionListTitle
347 | Tag::DefinitionListDefinition
348 | Tag::MetadataBlock(_) => {}
349 }
350 }
351
352 fn end(&mut self, tag: TagEnd) {
353 match tag {
354 TagEnd::Paragraph | TagEnd::HtmlBlock => self.raw("</p>"),
355 TagEnd::Heading(level) => self.raw(heading_close(level)),
356 TagEnd::BlockQuote(_) => self.raw("</blockquote>"),
357 TagEnd::CodeBlock => self.raw("</code></pre>"),
358 TagEnd::List(true) => self.raw("</ol>"),
359 TagEnd::List(false) => self.raw("</ul>"),
360 TagEnd::Item => self.raw("</li>"),
361 TagEnd::FootnoteDefinition => self.raw("</div>"),
362 TagEnd::Table => self.raw("</table></div>"),
363 TagEnd::TableHead => {
364 self.in_head = false;
365 self.raw("</tr></thead><tbody>");
366 }
367 TagEnd::TableRow => self.raw("</tr>"),
368 TagEnd::TableCell => {
369 self.column += 1;
370 if self.in_head {
371 self.raw("</th>");
372 } else {
373 self.raw("</td>");
374 }
375 }
376 TagEnd::Emphasis => self.raw("</em>"),
377 TagEnd::Strong => self.raw("</strong>"),
378 TagEnd::Strikethrough => self.raw("</del>"),
379 TagEnd::Superscript => self.raw("</sup>"),
380 TagEnd::Subscript => self.raw("</sub>"),
381 TagEnd::Link => self.raw("</a>"),
382 TagEnd::Image => self.image(),
383 TagEnd::DefinitionList
384 | TagEnd::DefinitionListTitle
385 | TagEnd::DefinitionListDefinition
386 | TagEnd::MetadataBlock(_) => {}
387 }
388 }
389
390 /// Writes the image whose alt text has just finished arriving.
391 ///
392 /// A refused source is not rendered as a broken image: the alt text is written on
393 /// its own, which is the same thing a reader with images off would get.
394 fn image(&mut self) {
395 let Some(image) = self.image.take() else {
396 return;
397 };
398
399 let Destination::Allowed(url) = image.destination else {
400 self.text(&image.alt);
401 return;
402 };
403
404 self.raw("<img class=\"my-4 max-w-full rounded-lg border border-border\"");
405 self.attribute("src", &url);
406 self.attribute("alt", &image.alt);
407 if !image.title.is_empty() {
408 self.attribute("title", &image.title);
409 }
410 self.raw(" />");
411 }
412}
413
414/// The class for a column's stated alignment, or `None` when it stated none.
415fn align_class(alignment: Option<Alignment>) -> Option<&'static str> {
416 match alignment? {
417 Alignment::Center => Some("text-center"),
418 Alignment::Right => Some("text-right"),
419 Alignment::Left => Some("text-left"),
420 Alignment::None => None,
421 }
422}
423
424/// Headings step down in weight as well as size, so a README's `##` sections read as
425/// sections rather than as six sizes of the same thing.
426fn heading_open(level: HeadingLevel) -> &'static str {
427 match level {
428 HeadingLevel::H1 => "<h1 class=\"mt-8 mb-3 text-2xl font-semibold tracking-tight\">",
429 HeadingLevel::H2 => {
430 "<h2 class=\"mt-8 mb-3 border-b border-border pb-2 text-xl font-semibold tracking-tight\">"
431 }
432 HeadingLevel::H3 => "<h3 class=\"mt-6 mb-2 text-lg font-semibold\">",
433 HeadingLevel::H4 => "<h4 class=\"mt-6 mb-2 text-base font-semibold\">",
434 HeadingLevel::H5 => "<h5 class=\"mt-4 mb-2 text-sm font-semibold\">",
435 HeadingLevel::H6 => "<h6 class=\"mt-4 mb-2 text-sm font-semibold text-muted-foreground\">",
436 }
437}
438
439fn heading_close(level: HeadingLevel) -> &'static str {
440 match level {
441 HeadingLevel::H1 => "</h1>",
442 HeadingLevel::H2 => "</h2>",
443 HeadingLevel::H3 => "</h3>",
444 HeadingLevel::H4 => "</h4>",
445 HeadingLevel::H5 => "</h5>",
446 HeadingLevel::H6 => "</h6>",
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 /// The rendered HTML, for a test to look inside.
455 fn html(source: &str) -> String {
456 render(source).as_str().to_owned()
457 }
458
459 /// The same, with a resolver for relative links.
460 fn html_with(source: &str, resolve: impl Fn(&str) -> Option<String>) -> String {
461 render_with_links(source, resolve).as_str().to_owned()
462 }
463
464 #[test]
465 fn headings_become_headings() {
466 assert!(html("# Steid").contains("<h1 class=\""));
467 assert!(html("# Steid").contains(">Steid</h1>"));
468 assert!(html("### Deeper").contains(">Deeper</h3>"));
469 }
470
471 #[test]
472 fn lists_keep_their_kind() {
473 let bullets = html("- one\n- two\n");
474 assert!(bullets.contains("<ul class="));
475 assert!(bullets.contains("<li>one</li>"));
476
477 let numbers = html("3. three\n4. four\n");
478 assert!(numbers.contains("<ol class="));
479 assert!(numbers.contains("start=\"3\""));
480 }
481
482 #[test]
483 fn a_fenced_code_block_is_a_pre_and_its_contents_are_text() {
484 let rendered = html("```rust\nlet x = 1 < 2;\n```\n");
485
486 assert!(rendered.contains("<pre class="));
487 assert!(rendered.contains("<code>let x = 1 &lt; 2;\n</code></pre>"));
488 }
489
490 #[test]
491 fn inline_code_is_a_code_element() {
492 assert!(html("use `cargo test`").contains("<code class=\"rounded"));
493 }
494
495 #[test]
496 fn a_table_renders_as_a_table_with_alignment() {
497 let rendered = html("| a | b |\n|:-:|--:|\n| 1 | 2 |\n");
498
499 assert!(rendered.contains("<table class="));
500 assert!(
501 rendered.contains(
502 "<th class=\"border-b border-border px-3 py-2 font-medium text-center\">"
503 )
504 );
505 assert!(rendered.contains("text-right"));
506 assert!(rendered.contains("<tbody><tr><td"));
507 }
508
509 #[test]
510 fn a_header_with_no_stated_alignment_is_left_aligned() {
511 // A browser centres `<th>` on its own, which reads as a deliberate choice.
512 let rendered = html("| a |\n|---|\n| 1 |\n");
513
514 assert!(rendered.contains("font-medium text-left\">"), "{rendered}");
515 }
516
517 #[test]
518 fn task_lists_render_as_disabled_checkboxes() {
519 let rendered = html("- [x] done\n- [ ] not\n");
520
521 assert!(rendered.contains("type=\"checkbox\" disabled"));
522 assert!(rendered.contains(" checked />"));
523 }
524
525 // --- Safety ---------------------------------------------------------------------
526
527 #[test]
528 fn a_script_tag_in_the_source_is_text_and_not_a_tag() {
529 let rendered = html("<script>alert(1)</script>\n");
530
531 assert!(
532 !rendered.contains("<script"),
533 "a script tag reached the page: {rendered}"
534 );
535 assert!(rendered.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
536 }
537
538 #[test]
539 fn an_inline_event_handler_is_text_and_not_a_tag() {
540 let rendered = html("Look: <img src=x onerror=\"alert(1)\"> at that.\n");
541
542 // The words are still there — they are *text* now. What matters is that no
543 // element was created for the handler to hang off.
544 assert!(
545 !rendered.contains("<img"),
546 "an image tag reached the page: {rendered}"
547 );
548 assert!(rendered.contains("&lt;img src=x onerror="));
549 }
550
551 #[test]
552 fn an_html_comment_cannot_hide_markup() {
553 let rendered = html("<!-- <script>alert(1)</script> -->\n");
554
555 assert!(!rendered.contains("<script"), "{rendered}");
556 assert!(!rendered.contains("<!--"), "{rendered}");
557 }
558
559 #[test]
560 fn a_javascript_link_loses_its_href() {
561 let rendered = html("[click](javascript:alert(1))");
562
563 assert!(!rendered.contains("javascript"), "{rendered}");
564 assert!(rendered.contains("<a>click</a>"), "{rendered}");
565 }
566
567 #[test]
568 fn a_javascript_url_split_by_a_control_character_still_loses_its_href() {
569 // A browser strips the tab before parsing the URL, so the check has to as well.
570 let rendered = html("[click](<java&#9;script:alert(1)>)");
571
572 assert!(!rendered.contains("href"), "{rendered}");
573 }
574
575 #[test]
576 fn an_uppercase_scheme_is_still_refused() {
577 assert!(!html("[click](JaVaScRiPt:alert(1))").contains("href"));
578 }
579
580 #[test]
581 fn a_data_url_image_is_refused_and_leaves_its_alt_text() {
582 let rendered = html("![a logo](data:text/html;base64,PHNjcmlwdD4=)");
583
584 assert!(!rendered.contains("<img"), "{rendered}");
585 assert!(rendered.contains("a logo"));
586 }
587
588 #[test]
589 fn ordinary_links_and_images_survive() {
590 assert!(
591 html("[home](https://example.com/a?b=1)")
592 .contains("href=\"https://example.com/a?b=1\"")
593 );
594 assert!(html("[mail](mailto:ada@example.com)").contains("href=\"mailto:ada@example.com\""));
595 assert!(
596 html("![logo](https://example.com/l.png)")
597 .contains("src=\"https://example.com/l.png\"")
598 );
599 assert!(html("[anchor](#usage)").contains("href=\"#usage\""));
600 }
601
602 #[test]
603 fn text_that_needs_escaping_is_escaped() {
604 let rendered = html("5 < 6 & \"quoted\" > 4\n");
605
606 assert!(rendered.contains("5 &lt; 6 &amp; \"quoted\" &gt; 4"));
607 }
608
609 #[test]
610 fn a_title_cannot_break_out_of_its_attribute() {
611 let rendered = html("[x](https://example.com \"a \\\" onmouseover=alert(1)\")");
612
613 assert!(rendered.contains("&quot;"), "{rendered}");
614 assert!(!rendered.contains("\" onmouseover"), "{rendered}");
615 }
616
617 // --- Relative links -------------------------------------------------------------
618
619 #[test]
620 fn a_relative_link_is_handed_to_the_resolver() {
621 let rendered = html_with("[c](./CONTRIBUTING.md)", |dest| {
622 Some(format!("/tree/{dest}"))
623 });
624
625 assert!(
626 rendered.contains("href=\"/tree/./CONTRIBUTING.md\""),
627 "{rendered}"
628 );
629 }
630
631 #[test]
632 fn absolute_paths_anchors_and_urls_are_not_resolved() {
633 let resolve = |_: &str| Some("/rewritten".to_owned());
634
635 for source in ["[a](/already)", "[a](#anchor)", "[a](https://example.com/)"] {
636 let rendered = html_with(source, resolve);
637 assert!(!rendered.contains("/rewritten"), "{source}: {rendered}");
638 }
639 }
640
641 #[test]
642 fn a_resolver_cannot_smuggle_in_a_dangerous_url() {
643 // The scheme check runs after resolution, on purpose.
644 let rendered = html_with("[a](whatever.md)", |_| {
645 Some("javascript:alert(1)".to_owned())
646 });
647
648 assert!(!rendered.contains("href"), "{rendered}");
649 }
650
651 #[test]
652 fn a_relative_image_is_never_resolved() {
653 // A tree URL serves a *page*, so rewriting an image source to one would swap a
654 // 404 for a broken image. The resolver is for links only.
655 let rendered = html_with("![l](logo.png)", |_| Some("/rewritten".to_owned()));
656
657 assert!(rendered.contains("src=\"logo.png\""), "{rendered}");
658 }
659
660 #[test]
661 fn without_a_resolver_a_relative_link_is_left_alone() {
662 assert!(html("[c](./CONTRIBUTING.md)").contains("href=\"./CONTRIBUTING.md\""));
663 }
664
665 #[test]
666 fn a_colon_in_a_path_is_not_a_scheme() {
667 assert!(scheme_of("docs/a:b.md").is_none());
668 assert!(scheme_of("#a:b").is_none());
669 assert!(scheme_of("./a.md").is_none());
670 assert_eq!(scheme_of("HTTPS://example.com").as_deref(), Some("https"));
671 }
672
673 #[test]
674 fn empty_input_renders_nothing() {
675 assert!(html("").is_empty());
676 assert!(html(" \n\n ").is_empty());
677 }
678}