| | @@ -0,0 +1,678 @@ |
| 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 | + |
| 25 | +use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; |
| 26 | +use 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. |
| 33 | +const ALLOWED_SCHEMES: &[&str] = &["http", "https", "mailto", "ftp", "ftps", "tel"]; |
| 34 | + |
| 35 | +/// Renders markdown to HTML, leaving relative links exactly as written. |
| 36 | +pub 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. |
| 50 | +pub 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. |
| 70 | +fn 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. |
| 78 | +enum 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. |
| 90 | +fn 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	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. |
| 129 | +fn 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. |
| 153 | +struct 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. |
| 166 | +struct 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 | + |
| 175 | +impl<'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. |
| 415 | +fn 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. |
| 426 | +fn 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 | + |
| 439 | +fn 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)] |
| 451 | +mod 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 < 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("<script>alert(1)</script>")); |
| 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("<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	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(""); |
| 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("") |
| 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 < 6 & \"quoted\" > 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("""), "{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("", |_| 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 | +} |