| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- /**
- * Hand-written regression suite for the markdown engine.
- *
- * Covers the three reported bugs (emphasis across a line break, single
- * newline must not start a new paragraph, wrapped list-item lines must stay
- * in the item) plus the security posture (raw HTML neutralised, URL scheme
- * filtering, entity smuggling) and edge cases (CRLF, empty input, tight vs
- * loose lists, setext headings).
- *
- * Usage: qjs tests/markdown/regressions.js
- */
- import * as std from 'std';
- import { installShim } from './shim.js';
- function fail(msg) {
- std.err.puts(msg + '\n');
- std.exit(1);
- }
- installShim();
- // Load order matters: statum-core.js creates `window.statum` (and the module
- // seam), statum-markdown.js registers the engine + its bindings through it.
- // Loading exactly these two proves the core+markdown cherry-pick.
- (0, eval)(std.loadFile('js/statum-core.js'));
- (0, eval)(std.loadFile('js/statum-markdown.js'));
- var render = globalThis.statum._testMarkdown;
- if (typeof render !== 'function') fail('statum._testMarkdown is not exposed');
- var passed = 0, failed = 0;
- function eq(name, input, expected) {
- var got;
- try {
- got = render(input);
- } catch (e) {
- got = '<<threw ' + e + '>>';
- }
- if (got === expected) {
- passed++;
- } else {
- failed++;
- print('FAIL: ' + name);
- print(' input: ' + JSON.stringify(input));
- print(' expected: ' + JSON.stringify(expected));
- print(' got: ' + JSON.stringify(got));
- }
- }
- function ok(name, input, test, description) {
- var got;
- try {
- got = render(input);
- } catch (e) {
- got = '<<threw ' + e + '>>';
- }
- if (test(got)) {
- passed++;
- } else {
- failed++;
- print('FAIL: ' + name + (description ? ' — ' + description : ''));
- print(' input: ' + JSON.stringify(input));
- print(' got: ' + JSON.stringify(got));
- }
- }
- // ---------------------------------------------------------------------------
- // The three reported bugs
- // ---------------------------------------------------------------------------
- eq('emphasis wraps across a newline',
- '**bold text\nthat wraps**\n',
- '<p><strong>bold text\nthat wraps</strong></p>\n');
- eq('single newline is a soft break, not a new paragraph',
- 'one\ntwo\n',
- '<p>one\ntwo</p>\n');
- eq('paragraphs only split on blank lines',
- 'aaa\nbbb\n\nccc\nddd\n',
- '<p>aaa\nbbb</p>\n<p>ccc\nddd</p>\n');
- eq('wrapped line stays inside the list item',
- '- item one\n continued line\n- item two\n',
- '<ul>\n<li>item one\ncontinued line</li>\n<li>item two</li>\n</ul>\n');
- eq('wrapped line in ordered list stays in the item',
- '1. first\n wrapped\n2. second\n',
- '<ol>\n<li>first\nwrapped</li>\n<li>second</li>\n</ol>\n');
- // ---------------------------------------------------------------------------
- // Block constructs
- // ---------------------------------------------------------------------------
- eq('ATX headings', '# Title\n###### Deep\n', '<h1>Title</h1>\n<h6>Deep</h6>\n');
- eq('setext headings', 'Title\n=====\nSub\n---\n', '<h1>Title</h1>\n<h2>Sub</h2>\n');
- eq('thematic break', '---\n', '<hr />\n');
- eq('fenced code with language', '```js\nvar x = 1;\n```\n',
- '<pre><code class="language-js">var x = 1;\n</code></pre>\n');
- eq('tilde fence', '~~~\nplain\n~~~\n', '<pre><code>plain\n</code></pre>\n');
- eq('indented code', ' code\n', '<pre><code>code\n</code></pre>\n');
- eq('blockquote with lazy continuation', '> quoted\nlazy\n',
- '<blockquote>\n<p>quoted\nlazy</p>\n</blockquote>\n');
- eq('tight list stays tight', '- a\n- b\n', '<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n');
- eq('loose list gets paragraphs', '- a\n\n- b\n',
- '<ul>\n<li>\n<p>a</p>\n</li>\n<li>\n<p>b</p>\n</li>\n</ul>\n');
- eq('nested list', '- a\n - b\n', '<ul>\n<li>a\n<ul>\n<li>b</li>\n</ul>\n</li>\n</ul>\n');
- eq('ordered list start attribute', '3. c\n4. d\n',
- '<ol start="3">\n<li>c</li>\n<li>d</li>\n</ol>\n');
- // ---------------------------------------------------------------------------
- // Inlines
- // ---------------------------------------------------------------------------
- eq('code span', 'a `code` b\n', '<p>a <code>code</code> b</p>\n');
- eq('code span with backslash inside', '``a\\`b``\n', '<p><code>a\\`b</code></p>\n');
- eq('backslash escape', '\\*not em\\*\n', '<p>*not em*</p>\n');
- eq('named entity', '© 2024\n', '<p>\u00A9 2024</p>\n');
- eq('numeric entity', '#\n', '<p>#</p>\n');
- eq('invalid entity stays literal', '&madeup;\n', '<p>&madeup;</p>\n');
- eq('intraword emphasis with underscores is not emphasis', 'foo_bar_baz\n',
- '<p>foo_bar_baz</p>\n');
- eq('intraword emphasis with asterisks', 'foo*bar*baz\n',
- '<p>foo<em>bar</em>baz</p>\n');
- eq('triple emphasis', '***both***\n', '<p><em><strong>both</strong></em></p>\n');
- eq('hard break with two spaces', 'foo \nbar\n', '<p>foo<br />\nbar</p>\n');
- eq('hard break with backslash', 'foo\\\nbar\n', '<p>foo<br />\nbar</p>\n');
- eq('inline link with title', '[t](/u "title")\n',
- '<p><a href="/u" title="title">t</a></p>\n');
- eq('image', '\n',
- '<p><img src="/img.png" alt="alt" title="cap" /></p>\n');
- eq('reference link', '[t][ref]\n\n[ref]: /u "ti"\n',
- '<p><a href="/u" title="ti">t</a></p>\n');
- eq('shortcut reference link', '[ref]\n\n[ref]: /u\n',
- '<p><a href="/u">ref</a></p>\n');
- eq('autolink http', '<https://example.com/>\n',
- '<p><a href="https://example.com/">https://example.com/</a></p>\n');
- eq('autolink mail', '<mail@example.com>\n',
- '<p><a href="mailto:mail@example.com">mail@example.com</a></p>\n');
- // ---------------------------------------------------------------------------
- // Security posture
- // ---------------------------------------------------------------------------
- ok('script tag is neutralised', '<script>alert(1)</script>\n',
- function (h) { return h.indexOf('<script') < 0 && h.indexOf('<script') >= 0; });
- ok('img onerror is neutralised', '<img src=x onerror=alert(1)>\n',
- function (h) { return h.indexOf('<img') < 0 && h.indexOf('<img') >= 0; },
- 'the tag must render as escaped text, not markup');
- ok('javascript: link href is replaced', '[click](javascript:alert(1))\n',
- function (h) { return h.indexOf('javascript:') < 0; },
- 'javascript: URLs must be filtered');
- ok('data: link href is replaced', '[x](data:text/html,<script>)\n',
- function (h) { return h.indexOf('data:') < 0; });
- ok('javascript: image src is replaced', ')\n',
- function (h) { return h.indexOf('javascript:') < 0; });
- ok('vbscript href is replaced', '[x](vbscript:msgbox)\n',
- function (h) { return h.indexOf('vbscript:') < 0; });
- eq('allowed schemes pass through', '[a](https://x) [b](http://x) [c](mailto:a@b) [d](ftp://x) /rel\n',
- '<p><a href="https://x">a</a> <a href="http://x">b</a> <a href="mailto:a@b">c</a> <a href="ftp://x">d</a> /rel</p>\n');
- ok('entity smuggling is decoded then escaped', '<script>alert(1)</script>\n',
- function (h) { return h.indexOf('<script') < 0 && h.indexOf('<script') >= 0; },
- 'numeric refs must decode to text, then be escaped on output');
- ok('hex entity smuggling is neutralised', '<script>\n',
- function (h) { return h.indexOf('<script') < 0 && h.indexOf('<script') >= 0; });
- ok('uppercase scheme javascript is filtered', '[x](JAVASCRIPT:alert(1))\n',
- function (h) { return h.toLowerCase().indexOf('javascript:') < 0; });
- ok('title attribute is escaped', '[x](/u ""><script>")\n',
- function (h) { return h.indexOf('<script') < 0; });
- // ---------------------------------------------------------------------------
- // Edge cases
- // ---------------------------------------------------------------------------
- eq('empty input renders empty', '', '');
- eq('blank-only input renders empty', '\n\n\n', '');
- eq('CRLF line endings', 'a\r\nb\r\n\r\nc\r\n', '<p>a\nb</p>\n<p>c</p>\n');
- eq('lone CR line endings', 'a\rb\r\rc\r', '<p>a\nb</p>\n<p>c</p>\n');
- // CommonMark: NUL is replaced by U+FFFD before parsing
- eq('NUL bytes are replaced', 'a\u0000b\n', '<p>a\uFFFDb</p>\n');
- eq('heading with trailing hashes', '## Title ##\n', '<h2>Title</h2>\n');
- eq('link label case-insensitive reference', '[Foo]\n\n[foo]: /u\n',
- '<p><a href="/u">Foo</a></p>\n');
- print('regressions: ' + passed + ' passed, ' + failed + ' failed');
- std.exit(failed ? 1 : 0);
|