worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // HTML construction: escaping, the page shell, and the stylesheet. |
| 2 | // |
| 3 | // Everything user-controlled — repository names, paths, commit messages, file |
| 4 | // contents — reaches a browser through this module, so escaping is centralised |
| 5 | // here. There is no template engine: a template language would be a second syntax |
| 6 | // to audit for the one thing that matters, which is that nothing interpolated is |
| 7 | // ever trusted. |
| 8 | |
| 9 | use crate::brand; |
| 10 | |
| 11 | /// Escape text for HTML body or attribute context. |
| 12 | /// |
| 13 | /// Handles both contexts, including quotes, so a caller cannot pick the wrong one. |
| 14 | /// Single quotes are escaped too, because an attribute written with single quotes is |
| 15 | /// otherwise an injection point and this function has no way to know which the |
| 16 | /// caller used. |
| 17 | pub fn escape(value: &str) -> String { |
| 18 | let mut out = String::with_capacity(value.len()); |
| 19 | for c in value.chars() { |
| 20 | match c { |
| 21 | '&' => out.push_str("&"), |
| 22 | '<' => out.push_str("<"), |
| 23 | '>' => out.push_str(">"), |
| 24 | '"' => out.push_str("""), |
| 25 | '\'' => out.push_str("'"), |
| 26 | _ => out.push(c), |
| 27 | } |
| 28 | } |
| 29 | out |
| 30 | } |
| 31 | |
| 32 | /// Percent-encode a path segment for use in a URL. |
| 33 | /// |
| 34 | /// Repository paths carry characters — `#`, `?`, spaces — that silently truncate or |
| 35 | /// redirect a link if they reach an href raw. |
| 36 | pub fn url_escape(value: &str) -> String { |
| 37 | let mut out = String::with_capacity(value.len()); |
| 38 | for byte in value.as_bytes() { |
| 39 | match byte { |
| 40 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { |
| 41 | out.push(*byte as char) |
| 42 | } |
| 43 | _ => out.push_str(&format!("%{byte:02X}")), |
| 44 | } |
| 45 | } |
| 46 | out |
| 47 | } |
| 48 | |
| 49 | /// Render markdown to HTML with raw HTML dropped. |
| 50 | /// |
| 51 | /// A README is attacker-controlled on any public host: anyone who can push can put |
| 52 | /// a `<script>` in it. pulldown-cmark passes raw HTML through by default, so the |
| 53 | /// tags are filtered out here rather than escaped — escaping would show the markup |
| 54 | /// as literal text, which is noise, while dropping it renders the readable content |
| 55 | /// and none of the payload. |
| 56 | pub fn markdown(source: &str) -> String { |
| 57 | use pulldown_cmark::{html, Event, Options, Parser, Tag}; |
| 58 | |
| 59 | let mut options = Options::empty(); |
| 60 | options.insert(Options::ENABLE_TABLES); |
| 61 | options.insert(Options::ENABLE_STRIKETHROUGH); |
| 62 | options.insert(Options::ENABLE_TASKLISTS); |
| 63 | options.insert(Options::ENABLE_FOOTNOTES); |
| 64 | |
| 65 | let parser = Parser::new_ext(source, options) |
| 66 | .filter(|event| { |
| 67 | !matches!( |
| 68 | event, |
| 69 | Event::Html(_) | Event::InlineHtml(_) | Event::FootnoteReference(_) |
| 70 | ) |
| 71 | }) |
| 72 | .map(|event| match event { |
| 73 | // pulldown-cmark emits link and image destinations verbatim, so |
| 74 | // `[x](javascript:…)` in a README becomes an executable href. Dropping |
| 75 | // the tags is not enough; the URLs have to be checked too. |
| 76 | Event::Start(Tag::Link { |
| 77 | link_type, |
| 78 | dest_url, |
| 79 | title, |
| 80 | id, |
| 81 | }) => Event::Start(Tag::Link { |
| 82 | link_type, |
| 83 | dest_url: safe_url(&dest_url).into(), |
| 84 | title, |
| 85 | id, |
| 86 | }), |
| 87 | Event::Start(Tag::Image { |
| 88 | link_type, |
| 89 | dest_url, |
| 90 | title, |
| 91 | id, |
| 92 | }) => Event::Start(Tag::Image { |
| 93 | link_type, |
| 94 | dest_url: safe_url(&dest_url).into(), |
| 95 | title, |
| 96 | id, |
| 97 | }), |
| 98 | other => other, |
| 99 | }); |
| 100 | |
| 101 | let mut out = String::new(); |
| 102 | html::push_html(&mut out, parser); |
| 103 | out |
| 104 | } |
| 105 | |
| 106 | /// Replace a URL that would execute with one that does nothing. |
| 107 | /// |
| 108 | /// An allow-list of schemes, not a block-list: `javascript:` is the obvious case but |
| 109 | /// `data:` can carry HTML and `vbscript:` still runs in some engines, and a |
| 110 | /// block-list is a list of the attacks somebody already thought of. Anything without |
| 111 | /// a scheme is a relative link and is left alone. |
| 112 | fn safe_url(url: &str) -> String { |
| 113 | // Control characters and whitespace are stripped first: `java\tscript:` and |
| 114 | // `java\nscript:` are both parsed as the scheme by browsers but defeat a naive |
| 115 | // prefix comparison. |
| 116 | let cleaned: String = url |
| 117 | .chars() |
| 118 | .filter(|c| !c.is_whitespace() && !c.is_control()) |
| 119 | .collect(); |
| 120 | |
| 121 | let Some((scheme, _)) = cleaned.split_once(':') else { |
| 122 | // No scheme at all — a relative link such as `docs/x.md` or `#anchor`. |
| 123 | return url.to_string(); |
| 124 | }; |
| 125 | |
| 126 | // A `:` appearing after a `/` or `?` is part of a path, not a scheme. |
| 127 | if scheme.contains('/') || scheme.contains('?') || scheme.contains('#') { |
| 128 | return url.to_string(); |
| 129 | } |
| 130 | |
| 131 | const ALLOWED: [&str; 4] = ["http", "https", "mailto", "ftp"]; |
| 132 | if ALLOWED.contains(&scheme.to_ascii_lowercase().as_str()) { |
| 133 | url.to_string() |
| 134 | } else { |
| 135 | // Rendered, but inert. Removing the link entirely would silently change what |
| 136 | // the document says. |
| 137 | "#blocked".to_string() |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | /// URL of the stylesheet, carrying the build so a deploy cannot serve stale CSS |
| 142 | /// from a cache told to keep it for a year. |
| 143 | pub fn style_url() -> String { |
| 144 | format!("/_{}/{}.css", brand::NAME, brand::COMMIT) |
| 145 | } |
| 146 | |
| 147 | pub struct Page { |
| 148 | pub title: String, |
| 149 | /// Rendered into the header bar, already escaped. |
| 150 | pub heading: String, |
| 151 | pub body: String, |
| 152 | } |
| 153 | |
| 154 | /// Wrap a page body in the shell. |
| 155 | pub fn document(page: &Page) -> String { |
| 156 | let mut out = String::with_capacity(page.body.len() + 2048); |
| 157 | out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n"); |
| 158 | out.push_str("<meta charset=\"utf-8\">\n"); |
| 159 | out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"); |
| 160 | out.push_str(&format!("<title>{}</title>\n", escape(&page.title))); |
| 161 | // Dark and light are both first-class; the browser picks. Declaring this stops |
| 162 | // form controls and scrollbars rendering light on a dark page. |
| 163 | out.push_str("<meta name=\"color-scheme\" content=\"light dark\">\n"); |
| 164 | out.push_str(&format!( |
| 165 | "<link rel=\"stylesheet\" href=\"{}\">\n", |
| 166 | style_url() |
| 167 | )); |
| 168 | out.push_str("</head>\n<body>\n"); |
| 169 | |
| 170 | out.push_str("<header class=\"bar\">\n<div class=\"wrap bar-inner\">\n"); |
| 171 | out.push_str(&format!( |
| 172 | "<a class=\"brand\" href=\"/\">{}</a>\n", |
| 173 | escape(brand::NAME) |
| 174 | )); |
| 175 | out.push_str(&format!("<div class=\"crumbs\">{}</div>\n", page.heading)); |
| 176 | out.push_str("</div>\n</header>\n"); |
| 177 | |
| 178 | out.push_str("<main class=\"wrap\">\n"); |
| 179 | out.push_str(&page.body); |
| 180 | out.push_str("\n</main>\n"); |
| 181 | |
| 182 | out.push_str("<footer class=\"wrap foot\">\n"); |
| 183 | out.push_str(&format!( |
| 184 | "<span>served by {} {}</span>\n", |
| 185 | escape(brand::NAME), |
| 186 | escape(brand::VERSION) |
| 187 | )); |
| 188 | out.push_str("</footer>\n"); |
| 189 | |
| 190 | out.push_str("</body>\n</html>\n"); |
| 191 | out |
| 192 | } |
| 193 | |
| 194 | pub const STYLE: &str = r#" |
| 195 | /* Repository viewer. |
| 196 | |
| 197 | No framework and no build step: the service ships as one binary, and a |
| 198 | stylesheet that needed compiling would mean a toolchain in the release path. */ |
| 199 | |
| 200 | *, *::before, *::after { box-sizing: border-box; } |
| 201 | |
| 202 | :root { |
| 203 | --bg: #ffffff; |
| 204 | --raised: #f7f8fa; |
| 205 | --line: #d8dce3; |
| 206 | --ink: #14171c; |
| 207 | --muted: #5b6472; |
| 208 | --link: #1f5fd0; |
| 209 | --accent: #7a3ddb; |
| 210 | --ok-bg: #e6f6ec; |
| 211 | --ok-ink: #1a7f40; |
| 212 | --warn-bg: #fdf1dd; |
| 213 | --warn-ink: #8a5a08; |
| 214 | --radius: 10px; |
| 215 | --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, |
| 216 | Consolas, "Liberation Mono", monospace; |
| 217 | --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", |
| 218 | Arial, sans-serif; |
| 219 | } |
| 220 | |
| 221 | @media (prefers-color-scheme: dark) { |
| 222 | :root { |
| 223 | --bg: #0f1216; |
| 224 | --raised: #161b22; |
| 225 | --line: #2a313b; |
| 226 | --ink: #e6e9ee; |
| 227 | --muted: #929cad; |
| 228 | --link: #74a8ff; |
| 229 | --accent: #b28bff; |
| 230 | --ok-bg: #12301f; |
| 231 | --ok-ink: #5fd08a; |
| 232 | --warn-bg: #33270f; |
| 233 | --warn-ink: #e0b055; |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | html { -webkit-text-size-adjust: 100%; } |
| 238 | |
| 239 | body { |
| 240 | margin: 0; |
| 241 | background: var(--bg); |
| 242 | color: var(--ink); |
| 243 | font-family: var(--sans); |
| 244 | font-size: 15px; |
| 245 | line-height: 1.6; |
| 246 | /* Kerning and ligatures off in tables of filenames would be better, but this is |
| 247 | body copy; the code views set their own. */ |
| 248 | font-variant-ligatures: none; |
| 249 | } |
| 250 | |
| 251 | .wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px; } |
| 252 | |
| 253 | a { color: var(--link); text-decoration: none; } |
| 254 | a:hover { text-decoration: underline; } |
| 255 | |
| 256 | /* ── header ─────────────────────────────────────────────────────────────── */ |
| 257 | |
| 258 | .bar { |
| 259 | border-bottom: 1px solid var(--line); |
| 260 | background: var(--raised); |
| 261 | position: sticky; |
| 262 | top: 0; |
| 263 | z-index: 10; |
| 264 | } |
| 265 | |
| 266 | .bar-inner { |
| 267 | display: flex; |
| 268 | align-items: center; |
| 269 | gap: 14px; |
| 270 | height: 52px; |
| 271 | } |
| 272 | |
| 273 | .brand { |
| 274 | font-weight: 650; |
| 275 | letter-spacing: -0.01em; |
| 276 | color: var(--ink); |
| 277 | font-size: 16px; |
| 278 | } |
| 279 | .brand:hover { text-decoration: none; color: var(--accent); } |
| 280 | |
| 281 | .crumbs { |
| 282 | font-size: 14px; |
| 283 | color: var(--muted); |
| 284 | overflow: hidden; |
| 285 | text-overflow: ellipsis; |
| 286 | white-space: nowrap; |
| 287 | } |
| 288 | .crumbs a { font-weight: 550; } |
| 289 | .crumbs .sep { color: var(--line); margin: 0 2px; } |
| 290 | |
| 291 | /* ── repository head ────────────────────────────────────────────────────── */ |
| 292 | |
| 293 | .repo-head { padding: 28px 0 18px; border-bottom: 1px solid var(--line); } |
| 294 | |
| 295 | .repo-title { |
| 296 | margin: 0; |
| 297 | font-size: 26px; |
| 298 | font-weight: 660; |
| 299 | letter-spacing: -0.02em; |
| 300 | display: flex; |
| 301 | align-items: center; |
| 302 | gap: 10px; |
| 303 | flex-wrap: wrap; |
| 304 | } |
| 305 | |
| 306 | .repo-desc { margin: 8px 0 0; color: var(--muted); max-width: 70ch; } |
| 307 | |
| 308 | .badge { |
| 309 | font-size: 11px; |
| 310 | font-weight: 600; |
| 311 | text-transform: uppercase; |
| 312 | letter-spacing: 0.06em; |
| 313 | padding: 3px 8px; |
| 314 | border-radius: 999px; |
| 315 | border: 1px solid var(--line); |
| 316 | color: var(--muted); |
| 317 | background: var(--bg); |
| 318 | } |
| 319 | .badge.public { background: var(--ok-bg); color: var(--ok-ink); border-color: transparent; } |
| 320 | |
| 321 | /* ── clone ──────────────────────────────────────────────────────────────── */ |
| 322 | |
| 323 | .clone { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; } |
| 324 | |
| 325 | .clone code { |
| 326 | font-family: var(--mono); |
| 327 | font-size: 12.5px; |
| 328 | background: var(--raised); |
| 329 | border: 1px solid var(--line); |
| 330 | border-radius: var(--radius); |
| 331 | padding: 7px 11px; |
| 332 | color: var(--ink); |
| 333 | user-select: all; |
| 334 | } |
| 335 | |
| 336 | /* ── toolbar ────────────────────────────────────────────────────────────── */ |
| 337 | |
| 338 | .toolbar { |
| 339 | display: flex; |
| 340 | align-items: center; |
| 341 | gap: 12px; |
| 342 | margin: 22px 0 12px; |
| 343 | flex-wrap: wrap; |
| 344 | } |
| 345 | |
| 346 | .picker { |
| 347 | font-family: inherit; |
| 348 | font-size: 13px; |
| 349 | color: var(--ink); |
| 350 | background: var(--raised); |
| 351 | border: 1px solid var(--line); |
| 352 | border-radius: var(--radius); |
| 353 | padding: 6px 10px; |
| 354 | } |
| 355 | |
| 356 | .spacer { flex: 1; } |
| 357 | |
| 358 | /* ── listing ────────────────────────────────────────────────────────────── */ |
| 359 | |
| 360 | .panel { |
| 361 | border: 1px solid var(--line); |
| 362 | border-radius: var(--radius); |
| 363 | overflow: hidden; |
| 364 | background: var(--bg); |
| 365 | } |
| 366 | |
| 367 | .panel + .panel { margin-top: 22px; } |
| 368 | |
| 369 | .panel-head { |
| 370 | padding: 10px 14px; |
| 371 | background: var(--raised); |
| 372 | border-bottom: 1px solid var(--line); |
| 373 | font-size: 13px; |
| 374 | color: var(--muted); |
| 375 | display: flex; |
| 376 | align-items: center; |
| 377 | gap: 8px; |
| 378 | } |
| 379 | |
| 380 | table.listing { width: 100%; border-collapse: collapse; } |
| 381 | |
| 382 | table.listing td { |
| 383 | padding: 8px 14px; |
| 384 | border-top: 1px solid var(--line); |
| 385 | vertical-align: middle; |
| 386 | } |
| 387 | table.listing tr:first-child td { border-top: none; } |
| 388 | table.listing tr:hover td { background: var(--raised); } |
| 389 | |
| 390 | td.name { width: 100%; } |
| 391 | td.name a { color: var(--ink); } |
| 392 | td.name a:hover { color: var(--link); } |
| 393 | |
| 394 | td.size, td.when { |
| 395 | color: var(--muted); |
| 396 | font-size: 12.5px; |
| 397 | text-align: right; |
| 398 | white-space: nowrap; |
| 399 | font-variant-numeric: tabular-nums; |
| 400 | } |
| 401 | |
| 402 | .icon { color: var(--muted); margin-right: 8px; display: inline-block; width: 1em; } |
| 403 | .icon.dir { color: var(--accent); } |
| 404 | |
| 405 | /* ── readme and prose ───────────────────────────────────────────────────── */ |
| 406 | |
| 407 | .prose { padding: 26px 30px; } |
| 408 | .prose > :first-child { margin-top: 0; } |
| 409 | .prose > :last-child { margin-bottom: 0; } |
| 410 | .prose h1, .prose h2, .prose h3 { letter-spacing: -0.02em; line-height: 1.3; margin: 1.6em 0 0.6em; } |
| 411 | .prose h1 { font-size: 25px; } |
| 412 | .prose h2 { font-size: 20px; border-bottom: 1px solid var(--line); padding-bottom: 6px; } |
| 413 | .prose h3 { font-size: 17px; } |
| 414 | .prose p, .prose ul, .prose ol { margin: 0 0 1em; } |
| 415 | .prose img { max-width: 100%; } |
| 416 | .prose blockquote { |
| 417 | margin: 0 0 1em; |
| 418 | padding: 2px 16px; |
| 419 | border-left: 3px solid var(--line); |
| 420 | color: var(--muted); |
| 421 | } |
| 422 | .prose code { |
| 423 | font-family: var(--mono); |
| 424 | font-size: 0.88em; |
| 425 | background: var(--raised); |
| 426 | padding: 2px 5px; |
| 427 | border-radius: 5px; |
| 428 | } |
| 429 | .prose pre { |
| 430 | background: var(--raised); |
| 431 | border: 1px solid var(--line); |
| 432 | border-radius: var(--radius); |
| 433 | padding: 14px 16px; |
| 434 | overflow-x: auto; |
| 435 | } |
| 436 | .prose pre code { background: none; padding: 0; font-size: 12.5px; } |
| 437 | .prose table { border-collapse: collapse; margin-bottom: 1em; } |
| 438 | .prose th, .prose td { border: 1px solid var(--line); padding: 6px 12px; } |
| 439 | .prose th { background: var(--raised); } |
| 440 | |
| 441 | /* ── code ───────────────────────────────────────────────────────────────── */ |
| 442 | |
| 443 | .code { overflow-x: auto; } |
| 444 | |
| 445 | table.code-table { |
| 446 | border-collapse: collapse; |
| 447 | font-family: var(--mono); |
| 448 | font-size: 12.5px; |
| 449 | line-height: 1.55; |
| 450 | width: 100%; |
| 451 | } |
| 452 | |
| 453 | table.code-table td.ln { |
| 454 | width: 1%; |
| 455 | min-width: 46px; |
| 456 | padding: 0 12px 0 14px; |
| 457 | text-align: right; |
| 458 | color: var(--muted); |
| 459 | user-select: none; |
| 460 | vertical-align: top; |
| 461 | border-right: 1px solid var(--line); |
| 462 | font-variant-numeric: tabular-nums; |
| 463 | } |
| 464 | |
| 465 | table.code-table td.ln a { color: inherit; } |
| 466 | table.code-table td.src { padding: 0 14px; white-space: pre; vertical-align: top; } |
| 467 | table.code-table tr:target td { background: var(--warn-bg); } |
| 468 | table.code-table tr:target td.ln { color: var(--warn-ink); } |
| 469 | |
| 470 | /* ── commits ────────────────────────────────────────────────────────────── */ |
| 471 | |
| 472 | .commit-row { display: flex; gap: 14px; align-items: baseline; } |
| 473 | .commit-subject { font-weight: 550; color: var(--ink); } |
| 474 | .commit-meta { color: var(--muted); font-size: 12.5px; } |
| 475 | |
| 476 | code.sha { |
| 477 | font-family: var(--mono); |
| 478 | font-size: 12px; |
| 479 | color: var(--muted); |
| 480 | background: var(--raised); |
| 481 | border: 1px solid var(--line); |
| 482 | border-radius: 6px; |
| 483 | padding: 1px 6px; |
| 484 | } |
| 485 | |
| 486 | pre.message { |
| 487 | font-family: var(--mono); |
| 488 | font-size: 13px; |
| 489 | white-space: pre-wrap; |
| 490 | word-break: break-word; |
| 491 | margin: 0; |
| 492 | padding: 16px 18px; |
| 493 | } |
| 494 | |
| 495 | .status { display: inline-block; width: 1.4em; font-family: var(--mono); color: var(--muted); } |
| 496 | .status.A { color: var(--ok-ink); } |
| 497 | .status.D { color: #d1424f; } |
| 498 | |
| 499 | /* ── notices ────────────────────────────────────────────────────────────── */ |
| 500 | |
| 501 | .empty, .notice { |
| 502 | padding: 34px 20px; |
| 503 | text-align: center; |
| 504 | color: var(--muted); |
| 505 | } |
| 506 | |
| 507 | .notice strong { color: var(--ink); display: block; font-size: 17px; margin-bottom: 6px; } |
| 508 | |
| 509 | .foot { |
| 510 | margin: 48px 0 30px; |
| 511 | padding-top: 18px; |
| 512 | border-top: 1px solid var(--line); |
| 513 | color: var(--muted); |
| 514 | font-size: 12.5px; |
| 515 | } |
| 516 | |
| 517 | @media (max-width: 640px) { |
| 518 | .wrap { padding: 0 14px; } |
| 519 | td.when { display: none; } |
| 520 | .repo-title { font-size: 21px; } |
| 521 | } |
| 522 | "#; |
| 523 | |
| 524 | #[cfg(test)] |
| 525 | mod tests { |
| 526 | use super::*; |
| 527 | |
| 528 | #[test] |
| 529 | fn escaping_covers_both_body_and_attribute_context() { |
| 530 | assert_eq!(escape("<script>"), "<script>"); |
| 531 | assert_eq!(escape("a&b"), "a&b"); |
| 532 | // Quotes matter: a repository description lands inside an attribute, and a |
| 533 | // bare quote there ends the attribute and starts writing markup. |
| 534 | assert_eq!(escape("\"onload=x"), ""onload=x"); |
| 535 | assert_eq!(escape("'onload=x"), "'onload=x"); |
| 536 | } |
| 537 | |
| 538 | #[test] |
| 539 | fn markdown_drops_embedded_html_rather_than_rendering_it() { |
| 540 | // Anyone who can push can write a README. On a public repository that is a |
| 541 | // stranger, so raw HTML in markdown is an attacker-supplied script tag. |
| 542 | let out = markdown("# hi\n\n<script>alert(1)</script>\n\n<img src=x onerror=y>"); |
| 543 | assert!(!out.contains("<script"), "{out}"); |
| 544 | assert!(!out.contains("onerror"), "{out}"); |
| 545 | assert!(out.contains("<h1>hi</h1>"), "{out}"); |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn a_markdown_link_cannot_carry_an_executable_scheme() { |
| 550 | // A README is attacker-controlled on a public repository, and pulldown-cmark |
| 551 | // emits destinations verbatim, so this is the one place markdown can still |
| 552 | // execute after raw HTML has been dropped. |
| 553 | for hostile in [ |
| 554 | "[x](javascript:alert(1))", |
| 555 | "[x](JaVaScRiPt:alert(1))", |
| 556 | "[x](java\tscript:alert(1))", |
| 557 | "[x](data:text/html;base64,PHNjcmlwdD4=)", |
| 558 | "[x](vbscript:msgbox)", |
| 559 | ")", |
| 560 | ] { |
| 561 | let out = markdown(hostile); |
| 562 | assert!( |
| 563 | !out.to_lowercase().contains("javascript:") |
| 564 | && !out.to_lowercase().contains("vbscript:") |
| 565 | && !out.to_lowercase().contains("data:text/html"), |
| 566 | "{hostile} rendered as {out}" |
| 567 | ); |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | #[test] |
| 572 | fn ordinary_links_are_left_alone() { |
| 573 | // Over-blocking would quietly break every README on the host. |
| 574 | for fine in [ |
| 575 | "[x](https://example.com/a?b=c#d)", |
| 576 | "[x](http://example.com)", |
| 577 | "[x](docs/guide.md)", |
| 578 | "[x](./relative)", |
| 579 | "[x](#anchor)", |
| 580 | "[x](mailto:a@b.c)", |
| 581 | ] { |
| 582 | let out = markdown(fine); |
| 583 | assert!(!out.contains("#blocked"), "{fine} was blocked: {out}"); |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | #[test] |
| 588 | fn markdown_still_escapes_text_that_looks_like_markup() { |
| 589 | let out = markdown("plain `<b>` and <not-a-tag>"); |
| 590 | assert!(!out.contains("<b>"), "{out}"); |
| 591 | } |
| 592 | |
| 593 | #[test] |
| 594 | fn a_url_segment_cannot_carry_a_query_or_fragment() { |
| 595 | // A file legitimately named `a?b#c` would otherwise produce a link that |
| 596 | // silently addresses a different path. |
| 597 | assert_eq!(url_escape("a?b#c"), "a%3Fb%23c"); |
| 598 | assert_eq!(url_escape("dir/name.txt"), "dir/name.txt"); |
| 599 | assert_eq!(url_escape("a b"), "a%20b"); |
| 600 | } |
| 601 | |
| 602 | #[test] |
| 603 | fn the_stylesheet_url_changes_when_the_build_does() { |
| 604 | // It is served immutable for a year, so a deploy that reused the URL would |
| 605 | // leave every returning visitor on the previous stylesheet. |
| 606 | assert!(style_url().contains(brand::COMMIT)); |
| 607 | } |
| 608 | } |