regressions.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. /**
  2. * Hand-written regression suite for the markdown engine.
  3. *
  4. * Covers the three reported bugs (emphasis across a line break, single
  5. * newline must not start a new paragraph, wrapped list-item lines must stay
  6. * in the item) plus the security posture (raw HTML neutralised, URL scheme
  7. * filtering, entity smuggling) and edge cases (CRLF, empty input, tight vs
  8. * loose lists, setext headings).
  9. *
  10. * Usage: qjs tests/markdown/regressions.js
  11. */
  12. import * as std from 'std';
  13. import { installShim } from './shim.js';
  14. function fail(msg) {
  15. std.err.puts(msg + '\n');
  16. std.exit(1);
  17. }
  18. installShim();
  19. (0, eval)(std.loadFile('js/statum.js'));
  20. var render = globalThis.statum._testMarkdown;
  21. if (typeof render !== 'function') fail('statum._testMarkdown is not exposed');
  22. var passed = 0, failed = 0;
  23. function eq(name, input, expected) {
  24. var got;
  25. try {
  26. got = render(input);
  27. } catch (e) {
  28. got = '<<threw ' + e + '>>';
  29. }
  30. if (got === expected) {
  31. passed++;
  32. } else {
  33. failed++;
  34. print('FAIL: ' + name);
  35. print(' input: ' + JSON.stringify(input));
  36. print(' expected: ' + JSON.stringify(expected));
  37. print(' got: ' + JSON.stringify(got));
  38. }
  39. }
  40. function ok(name, input, test, description) {
  41. var got;
  42. try {
  43. got = render(input);
  44. } catch (e) {
  45. got = '<<threw ' + e + '>>';
  46. }
  47. if (test(got)) {
  48. passed++;
  49. } else {
  50. failed++;
  51. print('FAIL: ' + name + (description ? ' — ' + description : ''));
  52. print(' input: ' + JSON.stringify(input));
  53. print(' got: ' + JSON.stringify(got));
  54. }
  55. }
  56. // ---------------------------------------------------------------------------
  57. // The three reported bugs
  58. // ---------------------------------------------------------------------------
  59. eq('emphasis wraps across a newline',
  60. '**bold text\nthat wraps**\n',
  61. '<p><strong>bold text\nthat wraps</strong></p>\n');
  62. eq('single newline is a soft break, not a new paragraph',
  63. 'one\ntwo\n',
  64. '<p>one\ntwo</p>\n');
  65. eq('paragraphs only split on blank lines',
  66. 'aaa\nbbb\n\nccc\nddd\n',
  67. '<p>aaa\nbbb</p>\n<p>ccc\nddd</p>\n');
  68. eq('wrapped line stays inside the list item',
  69. '- item one\n continued line\n- item two\n',
  70. '<ul>\n<li>item one\ncontinued line</li>\n<li>item two</li>\n</ul>\n');
  71. eq('wrapped line in ordered list stays in the item',
  72. '1. first\n wrapped\n2. second\n',
  73. '<ol>\n<li>first\nwrapped</li>\n<li>second</li>\n</ol>\n');
  74. // ---------------------------------------------------------------------------
  75. // Block constructs
  76. // ---------------------------------------------------------------------------
  77. eq('ATX headings', '# Title\n###### Deep\n', '<h1>Title</h1>\n<h6>Deep</h6>\n');
  78. eq('setext headings', 'Title\n=====\nSub\n---\n', '<h1>Title</h1>\n<h2>Sub</h2>\n');
  79. eq('thematic break', '---\n', '<hr />\n');
  80. eq('fenced code with language', '```js\nvar x = 1;\n```\n',
  81. '<pre><code class="language-js">var x = 1;\n</code></pre>\n');
  82. eq('tilde fence', '~~~\nplain\n~~~\n', '<pre><code>plain\n</code></pre>\n');
  83. eq('indented code', ' code\n', '<pre><code>code\n</code></pre>\n');
  84. eq('blockquote with lazy continuation', '> quoted\nlazy\n',
  85. '<blockquote>\n<p>quoted\nlazy</p>\n</blockquote>\n');
  86. eq('tight list stays tight', '- a\n- b\n', '<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n');
  87. eq('loose list gets paragraphs', '- a\n\n- b\n',
  88. '<ul>\n<li>\n<p>a</p>\n</li>\n<li>\n<p>b</p>\n</li>\n</ul>\n');
  89. eq('nested list', '- a\n - b\n', '<ul>\n<li>a\n<ul>\n<li>b</li>\n</ul>\n</li>\n</ul>\n');
  90. eq('ordered list start attribute', '3. c\n4. d\n',
  91. '<ol start="3">\n<li>c</li>\n<li>d</li>\n</ol>\n');
  92. // ---------------------------------------------------------------------------
  93. // Inlines
  94. // ---------------------------------------------------------------------------
  95. eq('code span', 'a `code` b\n', '<p>a <code>code</code> b</p>\n');
  96. eq('code span with backslash inside', '``a\\`b``\n', '<p><code>a\\`b</code></p>\n');
  97. eq('backslash escape', '\\*not em\\*\n', '<p>*not em*</p>\n');
  98. eq('named entity', '&copy; 2024\n', '<p>\u00A9 2024</p>\n');
  99. eq('numeric entity', '&#35;\n', '<p>#</p>\n');
  100. eq('invalid entity stays literal', '&madeup;\n', '<p>&amp;madeup;</p>\n');
  101. eq('intraword emphasis with underscores is not emphasis', 'foo_bar_baz\n',
  102. '<p>foo_bar_baz</p>\n');
  103. eq('intraword emphasis with asterisks', 'foo*bar*baz\n',
  104. '<p>foo<em>bar</em>baz</p>\n');
  105. eq('triple emphasis', '***both***\n', '<p><em><strong>both</strong></em></p>\n');
  106. eq('hard break with two spaces', 'foo \nbar\n', '<p>foo<br />\nbar</p>\n');
  107. eq('hard break with backslash', 'foo\\\nbar\n', '<p>foo<br />\nbar</p>\n');
  108. eq('inline link with title', '[t](/u "title")\n',
  109. '<p><a href="/u" title="title">t</a></p>\n');
  110. eq('image', '![alt](/img.png "cap")\n',
  111. '<p><img src="/img.png" alt="alt" title="cap" /></p>\n');
  112. eq('reference link', '[t][ref]\n\n[ref]: /u "ti"\n',
  113. '<p><a href="/u" title="ti">t</a></p>\n');
  114. eq('shortcut reference link', '[ref]\n\n[ref]: /u\n',
  115. '<p><a href="/u">ref</a></p>\n');
  116. eq('autolink http', '<https://example.com/>\n',
  117. '<p><a href="https://example.com/">https://example.com/</a></p>\n');
  118. eq('autolink mail', '<mail@example.com>\n',
  119. '<p><a href="mailto:mail@example.com">mail@example.com</a></p>\n');
  120. // ---------------------------------------------------------------------------
  121. // Security posture
  122. // ---------------------------------------------------------------------------
  123. ok('script tag is neutralised', '<script>alert(1)</script>\n',
  124. function (h) { return h.indexOf('<script') < 0 && h.indexOf('&lt;script') >= 0; });
  125. ok('img onerror is neutralised', '<img src=x onerror=alert(1)>\n',
  126. function (h) { return h.indexOf('<img') < 0 && h.indexOf('&lt;img') >= 0; },
  127. 'the tag must render as escaped text, not markup');
  128. ok('javascript: link href is replaced', '[click](javascript:alert(1))\n',
  129. function (h) { return h.indexOf('javascript:') < 0; },
  130. 'javascript: URLs must be filtered');
  131. ok('data: link href is replaced', '[x](data:text/html,<script>)\n',
  132. function (h) { return h.indexOf('data:') < 0; });
  133. ok('javascript: image src is replaced', '![x](javascript:alert(1))\n',
  134. function (h) { return h.indexOf('javascript:') < 0; });
  135. ok('vbscript href is replaced', '[x](vbscript:msgbox)\n',
  136. function (h) { return h.indexOf('vbscript:') < 0; });
  137. eq('allowed schemes pass through', '[a](https://x) [b](http://x) [c](mailto:a@b) [d](ftp://x) /rel\n',
  138. '<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');
  139. ok('entity smuggling is decoded then escaped', '&#60;script&#62;alert(1)&#60;/script&#62;\n',
  140. function (h) { return h.indexOf('<script') < 0 && h.indexOf('&lt;script') >= 0; },
  141. 'numeric refs must decode to text, then be escaped on output');
  142. ok('hex entity smuggling is neutralised', '&#x3c;script&#x3e;\n',
  143. function (h) { return h.indexOf('<script') < 0 && h.indexOf('&lt;script') >= 0; });
  144. ok('uppercase scheme javascript is filtered', '[x](JAVASCRIPT:alert(1))\n',
  145. function (h) { return h.toLowerCase().indexOf('javascript:') < 0; });
  146. ok('title attribute is escaped', '[x](/u ""><script>")\n',
  147. function (h) { return h.indexOf('<script') < 0; });
  148. // ---------------------------------------------------------------------------
  149. // Edge cases
  150. // ---------------------------------------------------------------------------
  151. eq('empty input renders empty', '', '');
  152. eq('blank-only input renders empty', '\n\n\n', '');
  153. eq('CRLF line endings', 'a\r\nb\r\n\r\nc\r\n', '<p>a\nb</p>\n<p>c</p>\n');
  154. eq('lone CR line endings', 'a\rb\r\rc\r', '<p>a\nb</p>\n<p>c</p>\n');
  155. // CommonMark: NUL is replaced by U+FFFD before parsing
  156. eq('NUL bytes are replaced', 'a\u0000b\n', '<p>a\uFFFDb</p>\n');
  157. eq('heading with trailing hashes', '## Title ##\n', '<h2>Title</h2>\n');
  158. eq('link label case-insensitive reference', '[Foo]\n\n[foo]: /u\n',
  159. '<p><a href="/u">Foo</a></p>\n');
  160. print('regressions: ' + passed + ' passed, ' + failed + ' failed');
  161. std.exit(failed ? 1 : 0);