All checks were successful
Deploy / build-deploy (push) Successful in 4m19s
182 lines
9.3 KiB
C++
182 lines
9.3 KiB
C++
/*
|
|
catcrafts.net
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
*/
|
|
|
|
// The Markdown renderer, which is the newest place untrusted text becomes
|
|
// markup — post bodies are fetched from someone else's server, so every one of
|
|
// these assertions is ultimately about the same thing: nothing in a body can
|
|
// escape into the document. The structural cases are here too, because a parser
|
|
// that silently drops a construct loses content invisibly.
|
|
|
|
import std;
|
|
import Catcrafts.Shared;
|
|
|
|
using namespace Catcrafts;
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
|
|
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
|
if (ok) return;
|
|
++failures;
|
|
std::println(std::cerr, "FAIL: {}{}{}", what,
|
|
got.empty() ? "" : " got: ", got);
|
|
}
|
|
|
|
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
|
|
Check(actual.View() == expected, what, actual.View());
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
auto md = [](std::string_view text,
|
|
std::span<const PostMedia> media = {}) {
|
|
return Markdown::Render(text, media);
|
|
};
|
|
|
|
// ── the guarantee ─────────────────────────────────────────────────
|
|
CheckEq(md("<script>alert(1)</script>"),
|
|
"<p><script>alert(1)</script></p>", "md: html is text, never markup");
|
|
CheckEq(md(")"),
|
|
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
|
|
R"(decoding="async" alt="x" src="#"></div>)",
|
|
"md: javascript: image source neutralised");
|
|
CheckEq(md("[x](javascript:alert(1))"),
|
|
R"(<p><a href="#" rel="noopener">x</a></p>)",
|
|
"md: javascript: link neutralised");
|
|
CheckEq(md("a \" b & c"), "<p>a " b & c</p>", "md: quotes and ampersands escaped");
|
|
// A code span is verbatim text, and verbatim is exactly where an escaper
|
|
// is most often forgotten.
|
|
CheckEq(md("`<b>`"), "<p><code><b></code></p>", "md: code span escaped");
|
|
|
|
// ── blocks ────────────────────────────────────────────────────────
|
|
CheckEq(md(""), "", "md: empty body renders nothing");
|
|
CheckEq(md("plain text"), "<p>plain text</p>", "md: paragraph");
|
|
// Demotion by one: the page h1 is the post title, so a body's own top-level
|
|
// heading is a section within it.
|
|
CheckEq(md("# Heading"), "<h2>Heading</h2>", "md: h1 demoted to h2");
|
|
CheckEq(md("### Heading"), "<h4>Heading</h4>", "md: h3 demoted to h4");
|
|
CheckEq(md("#nothashtag"), "<p>#nothashtag</p>", "md: # without a space is not a heading");
|
|
CheckEq(md("> quoted"),
|
|
R"(<blockquote class="post-body__quote"><p>quoted</p></blockquote>)",
|
|
"md: blockquote");
|
|
// The quoted lines are re-parsed, so a multi-paragraph quote keeps its
|
|
// paragraphs instead of collapsing into one run-on line.
|
|
CheckEq(md("> one\n>\n> two"),
|
|
R"(<blockquote class="post-body__quote"><p>one</p><p>two</p></blockquote>)",
|
|
"md: blockquote keeps its paragraphs");
|
|
CheckEq(md("- a\n- b"),
|
|
R"(<ul class="post-body__list"><li>a</li><li>b</li></ul>)", "md: unordered list");
|
|
CheckEq(md("1. a\n2. b"),
|
|
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)", "md: ordered list");
|
|
// A list resumed after an interrupting paragraph continues its numbering.
|
|
// Without the start attribute the mini-guide in one of these posts renders
|
|
// as steps 1-4 followed by steps 1, 2, 3.
|
|
CheckEq(md("5. e"),
|
|
R"(<ol class="post-body__list" start="5"><li>e</li></ol>)",
|
|
"md: ordered list keeps the number it announced");
|
|
// Blank lines between items are spacing, not seven one-item lists.
|
|
CheckEq(md("1. a\n\n2. b"),
|
|
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)",
|
|
"md: blank line inside a list does not split it");
|
|
CheckEq(md("---"), "<hr>", "md: thematic break");
|
|
CheckEq(md("- - -"), "<hr>", "md: spaced rule is not a one-item list");
|
|
// Whitespace in pasted terminal output is the content.
|
|
CheckEq(md("```\n a\tb\n```"),
|
|
"<pre class=\"post-body__code\"><code> a\tb\n</code></pre>",
|
|
"md: fenced code is verbatim");
|
|
// An unterminated fence must not swallow the document into nothing.
|
|
Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
|
|
"md: unterminated fence still renders its content");
|
|
|
|
// ── inline ────────────────────────────────────────────────────────
|
|
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
|
|
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
|
|
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
|
|
// Underscores are deliberately inert: these posts paste kernel symbol
|
|
// names into prose, and italicising half of one is worse than not
|
|
// italicising a word that used the underscore form.
|
|
CheckEq(md("kworker/u16:8-qc_ufs_qos_swq"),
|
|
"<p>kworker/u16:8-qc_ufs_qos_swq</p>", "md: underscores are not emphasis");
|
|
CheckEq(md("\\*literal\\*"), "<p>*literal*</p>", "md: backslash escape");
|
|
CheckEq(md("[label](https://x.example/y)"),
|
|
R"(<p><a href="https://x.example/y" rel="noopener">label</a></p>)", "md: link");
|
|
// Bare addresses are pasted constantly in these posts; leaving them inert
|
|
// would strip most of the outbound value out of the page.
|
|
CheckEq(md("see https://x.example/y"),
|
|
R"(<p>see <a href="https://x.example/y">https://x.example/y</a></p>)",
|
|
"md: bare URL autolinked");
|
|
|
|
// ── embedded media ────────────────────────────────────────────────
|
|
// A paragraph that is nothing but images becomes the same media block the
|
|
// cards use, rather than a <p> of pictures.
|
|
CheckEq(md(""),
|
|
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
|
|
R"(decoding="async" alt="a" src="/media/x.webp"></div>)",
|
|
"md: image-only paragraph is a media block");
|
|
Check(md("text ").View().starts_with("<p>text <img"),
|
|
"md: an image inside a sentence stays inline");
|
|
|
|
// Dimensions come from the sidecar list, because Markdown syntax has
|
|
// nowhere to carry them — and without them the prose below every
|
|
// screenshot jumps as the file arrives.
|
|
{
|
|
std::vector<PostMedia> media;
|
|
PostMedia img;
|
|
img.src = "/media/x.webp";
|
|
img.kind = "image";
|
|
img.avif = "/media/x.avif";
|
|
img.fallback = "/media/x.png";
|
|
img.width = 800;
|
|
img.height = 600;
|
|
media.push_back(img);
|
|
|
|
PostMedia vid;
|
|
vid.src = "/media/v.mp4";
|
|
vid.kind = "video";
|
|
vid.poster = "/media/v.poster.webp";
|
|
vid.fallback = "/media/v.h264.mp4";
|
|
vid.width = 1080;
|
|
vid.height = 1920;
|
|
media.push_back(vid);
|
|
|
|
const auto out = md("", media);
|
|
Check(out.View().find(R"(width="800" height="600")") != std::string_view::npos,
|
|
"md: inline image carries its dimensions", out.View());
|
|
// Routed through :Media, so a body image gets the same format ladder a
|
|
// card image does rather than a second, plainer implementation.
|
|
Check(out.View().find(R"(<source srcset="/media/x.avif" type="image/avif">)")
|
|
!= std::string_view::npos
|
|
&& out.View().find(R"(src="/media/x.png")") != std::string_view::npos,
|
|
"md: inline image gets the avif/png ladder", out.View());
|
|
|
|
// An inline video gets the same treatment a headline one does,
|
|
// fallback source and all.
|
|
const auto vout = md("", media);
|
|
Check(vout.View().find(R"(poster="/media/v.poster.webp")") != std::string_view::npos
|
|
&& vout.View().find("codecs=av01") != std::string_view::npos
|
|
&& vout.View().find(R"(<source src="/media/v.h264.mp4")") != std::string_view::npos,
|
|
"md: inline video gets poster and H.264 fallback", vout.View());
|
|
}
|
|
|
|
// ── termination ───────────────────────────────────────────────────
|
|
// Unbalanced delimiters are the classic way to hang a hand-written
|
|
// parser, and a body is input from someone else's server.
|
|
Check(!md("**unclosed").View().empty(), "md: unclosed strong terminates");
|
|
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
|
|
Check(!md(".View().empty(), "md: unclosed image terminates");
|
|
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
|
|
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
|
|
|
|
if (failures != 0) {
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|