test_template_ascii.py 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. #!/usr/bin/env python3
  2. """Template hygiene: stm-* expressions and <script> bodies must be pure ASCII.
  3. Why this invariant exists (verified against the live compiled app):
  4. statum-mkpstm HTML-escapes the entire compiled page - including
  5. <script> bodies and stm-* attribute EXPRESSION values - converting
  6. non-ASCII characters to named entities (&mdash;, &middot;, ...).
  7. * A <script> body is raw text in HTML: the browser never entity-decodes
  8. it. An entity landing there reaches JavaScript source verbatim and
  9. renders literally ("&mdash;" as text, or worse, a syntax error).
  10. * An stm-* attribute value IS entity-decoded by the parser - but the
  11. decoded ENTITY TEXT ("&mdash;", 7 characters) is what lands in the
  12. expression string, not an em dash. It evaluates and renders
  13. literally.
  14. Plain element body text is unaffected (the browser decodes it once,
  15. correctly), so non-ASCII and entities in body content are fine and are
  16. deliberately NOT checked here.
  17. The fix is app-side (the compiler is external): keep every stm-*
  18. expression and every <script> body pure ASCII. Inside expressions and JS
  19. string literals, use JS unicode escapes ('\u2014', '\u00b7', '\u2026',
  20. '\u2192') to keep the exact glyph; in prose-only strings (stm-confirm
  21. reaches window.confirm verbatim) prefer ASCII punctuation. This test
  22. makes that rule permanent.
  23. Standalone: python3 test/test_template_ascii.py
  24. """
  25. import os
  26. import re
  27. import sys
  28. import unittest
  29. PAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
  30. "..", "src", "pages")
  31. # stm-* attributes carry Statum expression/JS values (stm-text, stm-if,
  32. # stm-attribute.href, stm-action, stm-confirm, stm-values, ...). All of
  33. # them are treated as JS or passed through raw, so all must be ASCII.
  34. STM_ATTR_RE = re.compile(r'\b(stm-[A-Za-z0-9_-]+)\s*=\s*"([^"]*)"')
  35. # <script> without a pstm-res-src (that form has no body).
  36. SCRIPT_RE = re.compile(r'<script\b[^>]*>(.*?)</script>', re.S)
  37. def non_ascii(text):
  38. """[(offset, char)] for every non-ASCII character in text."""
  39. return [(i, c) for i, c in enumerate(text) if ord(c) >= 128]
  40. def extract_violations(path):
  41. """[(kind, detail)] for every non-ASCII character in an stm-*
  42. attribute value or a <script> body of one template."""
  43. with open(path, encoding="utf-8") as fh:
  44. src = fh.read()
  45. out = []
  46. for m in STM_ATTR_RE.finditer(src):
  47. for off, ch in non_ascii(m.group(2)):
  48. line = src.count("\n", 0, m.start() + off) + 1
  49. out.append(("stm attribute %s" % m.group(1),
  50. "line %d: U+%04X %r" % (line, ord(ch), ch)))
  51. for m in SCRIPT_RE.finditer(src):
  52. for off, ch in non_ascii(m.group(1)):
  53. line = src.count("\n", 0, m.start() + off) + 1
  54. out.append(("script body",
  55. "line %d: U+%04X %r" % (line, ord(ch), ch)))
  56. return out
  57. class TemplateAsciiTest(unittest.TestCase):
  58. def test_stm_expressions_and_scripts_are_ascii(self):
  59. pages = sorted(f for f in os.listdir(PAGES_DIR) if f.endswith(".html"))
  60. self.assertTrue(pages, "no templates found in src/pages")
  61. failures = []
  62. for name in pages:
  63. for kind, detail in extract_violations(os.path.join(PAGES_DIR, name)):
  64. failures.append("%s (%s): %s" % (name, kind, detail))
  65. self.assertEqual(
  66. failures, [],
  67. "stm-* expressions and <script> bodies must be pure ASCII "
  68. "(statum-mkpstm entity-encodes them; the browser never decodes "
  69. "scripts, and decoded entity text renders literally in "
  70. "expressions). Use \\uXXXX escapes or ASCII punctuation.\n"
  71. + "\n".join(failures))
  72. if __name__ == "__main__":
  73. unittest.main(verbosity=2)