diff --git a/.gitignore b/.gitignore index 33ee743b7..20e94e0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,7 @@ comfyworkflows_sharekey github-stats-cache.json pip_overrides.json *.json +# Track JSON test fixtures. +!tests/cases/*.json check2.sh -/venv/ \ No newline at end of file +/venv/ diff --git a/glob/manager_server.py b/glob/manager_server.py index 4b8826260..abb1e8af0 100644 --- a/glob/manager_server.py +++ b/glob/manager_server.py @@ -304,7 +304,6 @@ print_comfyui_version() core.check_invalid_nodes() - def setup_environment(): git_exe = core.get_config()['git_exe'] @@ -969,14 +968,22 @@ async def update_all(request): def convert_markdown_to_html(input_text): + """Parse source markdown, escaping text and URLs at their HTML boundaries.""" pattern_a = re.compile(r'\[a/([^]]+)]\(([^)]+)\)') pattern_w = re.compile(r'\[w/([^]]+)]') pattern_i = re.compile(r'\[i/([^]]+)]') pattern_bold = re.compile(r'\*\*([^*]+)\*\*') pattern_white = re.compile(r'%%([^*]+)%%') + # Format link labels separately from URLs; escape URLs when inserted. + hrefs = [] + def replace_a(match): - return f"{match.group(1)}" + # Entities written in the source URL (e.g. &) retain their meaning. + url = manager_util.unescape_html_entities(match.group(2)) + hrefs.append(manager_util.sanitize_url(url)) + text = manager_util.escape_html_text(match.group(1)) + return f"{text}" def replace_w(match): return f"
{match.group(1)}
" @@ -990,20 +997,35 @@ def convert_markdown_to_html(input_text): def replace_white(match): return f"{match.group(1)}" - input_text = input_text.replace('\\[', '[').replace('\\]', ']').replace('<', '<').replace('>', '>') + # NUL dropped first so the input cannot forge the href placeholders. + input_text = input_text.replace('\x00', '') + input_text = input_text.replace('\\[', '[').replace('\\]', ']') - result_text = re.sub(pattern_a, replace_a, input_text) + # Parse links before escaping prose, so generated text entities never enter URLs. + parts = [] + start = 0 + for match in pattern_a.finditer(input_text): + parts.append(manager_util.sanitize_tag(input_text[start:match.start()])) + parts.append(replace_a(match)) + start = match.end() + parts.append(manager_util.sanitize_tag(input_text[start:])) + result_text = ''.join(parts) result_text = re.sub(pattern_w, replace_w, result_text) result_text = re.sub(pattern_i, replace_i, result_text) result_text = re.sub(pattern_bold, replace_bold, result_text) result_text = re.sub(pattern_white, replace_white, result_text) + result_text = result_text.replace("\n", "
& after'
+ result = page.evaluate("""({text, keyword}) => {
+ const cell = document.createElement('div');
+ cell.id = 'cell';
+ cell.textContent = text;
+ document.body.append(cell);
+ grid.highlightKeywordsSync([cell], [keyword]);
+ return {text: cell.textContent, marks: [...cell.querySelectorAll('mark')].map(x => x.textContent),
+ images: cell.querySelectorAll('img').length};
+ }""", {"text": text, "keyword": keyword})
+ assert result == {"text": text, "marks": [keyword], "images": 0}
+ assert page.evaluate("window.__probe === undefined")
+
+
+def test_keyword_order_continues_across_text_nodes(page):
+ result = page.evaluate("""() => {
+ const cell = document.createElement('div');
+ cell.innerHTML = 'Alpha beta Alpha beta ';
+ grid.highlightKeywordsSync([cell], ['alpha', 'beta']);
+ return [...cell.querySelectorAll('mark')].map(x => x.textContent);
+ }""")
+ assert result == ["Alpha", "beta", "Alpha", "beta"]
+
+
+def test_search_cache_does_not_execute_html(page):
+ result = page.evaluate("""() => {
+ const row = {description: '
Alpha & Beta'};
+ const matched = grid.highlightKeywordsFilter(row, ['description'], 'alpha & beta');
+ return {matched, text: row.tg_text_description, highlight: row.tg_highlight_description};
+ }""")
+ assert result == {"matched": True, "text": "Alpha & Beta", "highlight": True}
+ page.wait_for_timeout(50)
+ assert page.evaluate("window.__probe === undefined")
+
+
+def test_search_preserves_order_columns_and_empty_query(page):
+ result = page.evaluate("""() => {
+ const row = {name: 'Alpha Beta', description: 'Gamma', missing: null, count: 0};
+ const columns = ['name', 'description', 'missing', 'count'];
+ const ordered = grid.highlightKeywordsFilter(row, columns, ' ALPHA beta ');
+ const reversed = grid.highlightKeywordsFilter(row, columns, 'beta alpha');
+ const acrossColumns = grid.highlightKeywordsFilter(row, columns, 'alpha gamma');
+ const zero = grid.highlightKeywordsFilter(row, columns, '0');
+ const empty = grid.highlightKeywordsFilter(row, columns, '');
+ return {ordered, reversed, acrossColumns, zero, empty,
+ flags: columns.map(column => row['tg_highlight_' + column])};
+ }""")
+ assert result == {"ordered": True, "reversed": False, "acrossColumns": False,
+ "zero": True, "empty": True, "flags": [None] * 4}
+
+
+def test_custom_text_generator_and_cache_keys(page):
+ result = page.evaluate("""() => {
+ Object.assign(grid.options.highlightKeywords, {
+ textKey: 'text_', highlightKey: 'mark_',
+ textGenerator: (row, column) => row[column].label
+ });
+ const row = {name: {label: 'Model <Flux>'}};
+ const matched = grid.highlightKeywordsFilter(row, ['name'], 'model markup and fresh single quotes back into the attribute. That
+ both corrupted legitimate URLs and undid the escaping.
+ """
+
+ def test_percent_markers_in_a_url_leave_the_href_intact(self):
+ html = CONVERT("[a/t](https://e.com/%%A%%)")
+ self.assertIn("href='https://e.com/%%A%%'", html)
+ self.assertNotIn("", html)
+
+ def test_note_markers_in_a_url_leave_the_href_intact(self):
+ html = CONVERT("[a/t](https://e.com/[w/A])")
+ self.assertIn("href='https://e.com/[w/A]'", html)
+ self.assertNotIn("cm-warn-note", html)
+
+ def test_no_element_leaks_out_of_a_url(self):
+ # Parsed rather than string-matched: the anchor must be the ONLY element.
+ html = CONVERT("[a/t](https://e.com/%%A%%and**B**)")
+ self.assertEqual(_parse_element_names(html), ["a"])
+
+ def test_nested_markdown_in_the_LINK_TEXT_still_renders(self):
+ # Protecting the href must not cost the label. Moving the anchor
+ # substitution last would have escaped these into visible mojibake.
+ self.assertIn("bold", CONVERT("[a/**bold** text](https://e.com)"))
+ self.assertIn("hi", CONVERT("[a/%%hi%% there](https://e.com)"))
+
+ def test_markup_outside_a_link_is_unaffected(self):
+ html = CONVERT("%%white%% and **bold** and [w/warn]")
+ self.assertIn("white", html)
+ self.assertIn("bold", html)
+ self.assertIn("cm-warn-note", html)
+
+ def test_the_newline_pass_cannot_reach_inside_the_href(self):
+ # The newline substitution is the LAST thing convert_markdown_to_html
+ # does, so restoring the href before it let '
' be injected into the
+ # attribute the escaper had secured. Not exploitable — '
' carries no
+ # quote — but it is the same class of breach this function exists to fix,
+ # so the invariant is pinned all the way to the returned string.
+ html = CONVERT("[a/t](https://e.com/a\nb)")
+ anchors = _parse_anchors(html)
+ self.assertEqual(len(anchors), 1)
+ self.assertNotIn("
", anchors[0]["href"])
+ self.assertEqual(_parse_element_names(html), ["a"])
+
+ def test_newlines_outside_a_link_still_become_line_breaks(self):
+ html = CONVERT("first\nsecond")
+ self.assertIn("
", html)
+
+ def test_a_forged_placeholder_in_the_input_cannot_hijack_restoration(self):
+ html = CONVERT("\x00H0\x00 [a/t](https://e.com/real)")
+ self.assertIn("href='https://e.com/real'", html)
+ self.assertEqual(html.count("https://e.com/real"), 1)
+
+
+class TestUrlSchemeAllowlist(unittest.TestCase):
+ def test_helpers_are_public_on_manager_util(self):
+ self.assertTrue(callable(getattr(MANAGER_UTIL, "escape_html_attribute", None)))
+ self.assertTrue(callable(getattr(MANAGER_UTIL, "sanitize_url", None)))
+
+ def test_dangerous_schemes_are_rejected(self):
+ for bad in [
+ "javascript:alert`1`",
+ "JaVaScRiPt:alert`1`",
+ " javascript:alert`1`",
+ "java\tscript:alert`1`",
+ "java\nscript:alert`1`",
+ "\x01javascript:alert`1`",
+ "data:text/html;base64,PHNjcmlwdD4=",
+ "vbscript:msgbox",
+ ]:
+ with self.subTest(bad=bad):
+ self.assertEqual(MANAGER_UTIL.sanitize_url(bad), "#")
+
+ def test_safe_urls_pass_through(self):
+ for good in [
+ "https://example.com/a?b=1",
+ "http://example.com",
+ "HTTPS://Example.COM/Path",
+ "/relative/path",
+ "relative/path",
+ "#in-page-anchor",
+ "//protocol-relative/path",
+ # A relative link may legitimately contain '&'. An earlier version
+ # rejected this shape while guarding against an entity-hidden colon;
+ # the guarantee comes from the escape layer instead (below), so the
+ # rejection was redundant and only broke real links.
+ "a&b/c",
+ "docs/page?x=1&y=2",
+ ]:
+ with self.subTest(good=good):
+ self.assertEqual(MANAGER_UTIL.sanitize_url(good), good)
+
+ def test_entity_hidden_colon_is_inert_after_escaping(self):
+ # Asserted on the POST-ESCAPE href, because that is where the safety
+ # actually comes from: sanitize_url returns these unchanged (they have no
+ # real colon, so they read as relative), and escape_html_attribute then
+ # escapes the '&'. Entity decoding in an attribute is single-pass, so
+ # what the browser ends up with is a literal string, never a scheme.
+ for hidden in ["javascript:alert`1`", "javascript:alert`1`",
+ "javascript:alert`1`"]:
+ with self.subTest(hidden=hidden):
+ href = MANAGER_UTIL.escape_html_attribute(MANAGER_UTIL.sanitize_url(hidden))
+ self.assertIn("&", href)
+ self.assertNotIn(":", href)
+ self.assertNotIn("j", href)
+ self.assertNotIn("j", href)
+
+ def test_entity_hidden_colon_in_markdown_produces_no_live_scheme(self):
+ html = CONVERT("[a/click](javascript:alert`1`)")
+ anchors = _parse_anchors(html)
+ self.assertEqual(len(anchors), 1)
+ # The parser decodes entities exactly as a browser would; after that the
+ # href must NOT be a javascript: URL.
+ self.assertFalse(anchors[0]["href"].lower().startswith("javascript:"))
+
+ def test_dangerous_scheme_in_markdown_does_not_reach_the_href(self):
+ html = CONVERT("[a/click](javascript:alert`1`)")
+ self.assertNotIn("javascript:", html.lower())
+ self.assertIn("href='#'", html)
+
+
+class TestAttributeEscaper(unittest.TestCase):
+ def test_escapes_all_five_characters(self):
+ self.assertEqual(
+ MANAGER_UTIL.escape_html_attribute("""&<>"'"""),
+ "&<>"'",
+ )
+
+ def test_ampersand_is_escaped_first_and_only_once(self):
+ self.assertEqual(MANAGER_UTIL.escape_html_attribute("a&b"), "a&b")
+
+ def test_non_string_input_is_coerced(self):
+ self.assertEqual(MANAGER_UTIL.escape_html_attribute(None), "None")
+
+
+class TestServerTitleTransformIsTheOnlyUpstreamControl(unittest.TestCase):
+ """Pin the server-side facts that the bare flyover title sinks rest on.
+
+ js/custom-nodes-manager.js says, at both flyover pack-title sinks, that the
+ title is already escaped by populate_markdown and so must NOT be escaped
+ again at the client (a second escape would double-escape a legitimate
+ <>-title into mojibake). That comment is a load-bearing claim about THIS
+ file's subject: it is the whole reason those two sinks are bare. These
+ cases make the claim machine-checked, so if the server side ever stops
+ covering `title` the suite says so, instead of leaving a comment asserting
+ a protection that is gone.
+ """
+
+ def test_populate_markdown_escapes_the_title_before_it_reaches_the_client(self):
+ # The specific link the sink comments name. This is the case that fails
+ # if someone drops `title` from populate_markdown, which is the drift
+ # the bare sinks are actually exposed to.
+ populate_markdown = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
+ pack = {"title": "Nodes for "), "<img>")
+
+ def test_sanitize_tag_does_not_escape_quotes_or_ampersand(self):
+ # Documented limit, not a defect: both title interpolations are in
+ # element-text position, where quotes are inert. It IS the reason a
+ # title moving into attribute position would need escaping.
+ self.assertEqual(MANAGER_UTIL.sanitize_tag("\"a\" & 'b'"), "\"a\" & 'b'")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_message_sink_provenance.py b/tests/test_message_sink_provenance.py
new file mode 100644
index 000000000..8a662341a
--- /dev/null
+++ b/tests/test_message_sink_provenance.py
@@ -0,0 +1,487 @@
+"""Inventory the current direct message-sink calls and reviewed error builders.
+
+These sinks accept HTML because some callers render buttons and server-escaped
+metadata. Unknown arguments or assignments require a provenance review. Runtime
+rendering tests separately verify that raw text stays inert and escaped titles
+remain readable; this source inventory is not a general JavaScript dataflow check.
+"""
+import re
+import tempfile
+import unittest
+from collections import Counter
+from pathlib import Path
+from unittest.mock import patch
+
+from js_lift import _skip_literal
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+JS_DIR = REPO_ROOT / "js"
+
+SINK_FILES = [
+ "common.js",
+ "custom-nodes-manager.js",
+ "model-manager.js",
+ "node-usage-analyzer.js",
+]
+
+SINK_METHODS = r"showStatus|showMessage|showError|showSelection"
+
+# Capture receivers so a new alias is reviewed instead of silently omitted.
+CALL_RE = re.compile(
+ r"(? str:
+ """Read a complete argument, including multiline templates."""
+ depth = 1
+ end = start
+ while depth:
+ skipped = _skip_literal(source, end)
+ if skipped != end:
+ end = skipped
+ continue
+ if source[end] == "(":
+ depth += 1
+ elif source[end] == ")":
+ depth -= 1
+ end += 1
+ if source[end:end + 1] == ";":
+ end += 1
+ return re.sub(r"\s+", " ", source[start:end].strip())
+
+
+def _call_sites():
+ """Return (file, line, receiver, method, argument) for direct calls."""
+ sites = []
+ for name in SINK_FILES:
+ source = (JS_DIR / name).read_text(encoding="utf-8")
+ matches = list(CALL_RE.finditer(source))
+ parsed = {match.end() for match in matches}
+ for candidate in SINK_CALL_RE.finditer(source):
+ if candidate.end() not in parsed:
+ lineno = source.count("\n", 0, candidate.start()) + 1
+ raise AssertionError(f"Unsupported message-sink call at {name}:{lineno}")
+ for match in matches:
+ lineno = source.count("\n", 0, match.start()) + 1
+ sites.append((name, lineno, match.group(1), match.group(2),
+ _argument(source, match.end())))
+ return sites
+
+
+# ---------------------------------------------------------------------------
+# THE REGISTRY. Keyed by (file, method, argument-source). Every showStatus and
+# showMessage call site must appear here with a provenance verdict and a reason.
+# ---------------------------------------------------------------------------
+REGISTRY = {
+ # --- custom-nodes-manager.js -------------------------------------------
+ ("custom-nodes-manager.js", "showStatus",
+ "`${prevViewRowsLength.toLocaleString()} custom nodes`);"):
+ (NO_DATA, "a formatted row count"),
+ ("custom-nodes-manager.js", "showStatus",
+ "`Loading node mappings (${mode}) ...`);"):
+ (NO_DATA, "`mode` is a datasrc-combo value chosen in the UI, not channel data"),
+ ("custom-nodes-manager.js", "showStatus",
+ "`${label} ${item.title} ...`);"):
+ (SERVER_ESCAPED,
+ "item.title comes from /customnode/getlist, where populate_markdown -> "
+ "sanitize_tag has already escaped it; escaping again shows entities"),
+ ("custom-nodes-manager.js", "showStatus",
+ "`${label} ${Object.keys(result).length} custom node(s) successfully`);"):
+ (NO_DATA, "a count of queue results"),
+ ("custom-nodes-manager.js", "showStatus",
+ "`Loading missing nodes (${mode}) ...`);"):
+ (NO_DATA, "UI-chosen mode"),
+ ("custom-nodes-manager.js", "showStatus",
+ "`Loading alternatives (${mode}) ...`);"):
+ (NO_DATA, "UI-chosen mode"),
+ ("custom-nodes-manager.js", "showStatus",
+ "`Loading workflow usage analysis ...`);"):
+ (NO_DATA, "hardcoded"),
+ ("custom-nodes-manager.js", "showStatus",
+ "`Loading custom nodes (${mode}) ...`);"):
+ (NO_DATA, "UI-chosen mode"),
+ ("custom-nodes-manager.js", "showMessage",
+ "`To apply the installed/updated/disabled/enabled custom node, please restart "
+ "ComfyUI. And refresh browser.`, \"red\");"):
+ (NO_DATA, "hardcoded"),
+ ("custom-nodes-manager.js", "showMessage", "\"\");"):
+ (NO_DATA, "clears the banner"),
+
+ # --- model-manager.js ---------------------------------------------------
+ ("model-manager.js", "showStatus",
+ "`${grid.viewRows.length.toLocaleString()} external models`);"):
+ (NO_DATA, "a formatted row count"),
+ ("model-manager.js", "showStatus", "`Install ${item.name} ...`);"):
+ (SERVER_ESCAPED,
+ "item.name comes from /externalmodel/getlist, where populate_markdown -> "
+ "sanitize_tag has already escaped it. WI-112 added a caller escape here "
+ "and it rendered '<Flux>' to the user; removing it is the fix"),
+ ("model-manager.js", "showStatus", "`Install ${result.length} models successfully`);"):
+ (NO_DATA, "a count of queue results"),
+ ("model-manager.js", "showStatus", "`Loading external model list ...`);"):
+ (NO_DATA, "hardcoded"),
+ ("model-manager.js", "showMessage",
+ "`To apply the installed model, please click the 'Refresh' button.`, \"red\")"):
+ (NO_DATA, "hardcoded"),
+ ("model-manager.js", "showMessage", "\"\");"):
+ (NO_DATA, "clears the banner"),
+
+ # --- node-usage-analyzer.js --------------------------------------------
+ ("node-usage-analyzer.js", "showStatus",
+ "`${grid.viewRows.length.toLocaleString()} installed packages`);"):
+ (NO_DATA, "a formatted row count"),
+ ("node-usage-analyzer.js", "showStatus",
+ "`Install ${sanitizeHTML(String(item.name))} ...`);"):
+ (RAW,
+ "item.name is packKey (loadData sets `name: packKey`) — a raw dict key no "
+ "server transform touches, so this caller escapes it. The ONLY raw-provenance "
+ "showStatus caller in the four files"),
+ ("node-usage-analyzer.js", "showStatus", "msg);"):
+ (SERVER_ESCAPED,
+ "uninstallNodes preserves the escaped title or escapes the raw name fallback; "
+ "test_node_usage_escaping executes that path through the panel sink"),
+ ("node-usage-analyzer.js", "showStatus",
+ "`Uninstalled ${targets.length} custom node(s) successfully`);"):
+ (NO_DATA, "a count of targets"),
+ ("node-usage-analyzer.js", "showStatus",
+ "`Install ${Object.keys(result).length} models successfully`);"):
+ (NO_DATA, "a count of queue results"),
+ ("node-usage-analyzer.js", "showStatus",
+ "`Uninstall ${Object.keys(result).length} custom node(s) successfully`);"):
+ (NO_DATA, "a count of queue results"),
+ ("node-usage-analyzer.js", "showStatus", "`Analyzing node usage ...`);"):
+ (NO_DATA, "hardcoded"),
+ ("node-usage-analyzer.js", "showMessage",
+ "`To apply the uninstalled custom nodes, please restart ComfyUI and refresh "
+ "browser.`, \"red\");"):
+ (NO_DATA, "hardcoded"),
+ ("node-usage-analyzer.js", "showMessage",
+ "`To apply the installed model, please click the 'Refresh' button.`, \"red\");"):
+ (NO_DATA, "hardcoded"),
+ ("node-usage-analyzer.js", "showMessage", "\"No workflows were found for analysis.\");"):
+ (NO_DATA, "hardcoded"),
+ ("node-usage-analyzer.js", "showMessage", "\"\");"):
+ (NO_DATA, "clears the banner"),
+
+ # --- showError's own delegation, in each of the three classes -----------
+ # Not an application caller: this IS `showError(err) { this.showMessage(err,
+ # "red"); }`. Whether `err` is display-ready is the showError CALLER's
+ # responsibility, which HtmlByDesignSinkTest below enforces separately. It is
+ # registered rather than pattern-excluded so that if showError ever stops
+ # forwarding — or starts forwarding something else — this entry goes stale
+ # and the guard says so.
+ ("custom-nodes-manager.js", "showMessage", "err, \"red\");"):
+ (FORWARDED, "showError delegation; err is escaped by the showError caller"),
+ ("model-manager.js", "showMessage", "err, \"red\");"):
+ (FORWARDED, "showError delegation; err is escaped by the showError caller"),
+ ("node-usage-analyzer.js", "showMessage", "err, \"red\");"):
+ (FORWARDED, "showError delegation; err is escaped by the showError caller"),
+}
+
+DISPLAY_READY_METHODS = ("showStatus", "showMessage")
+
+# A caller escape is spelled this way throughout the UI files.
+ESCAPE_MARKER = "sanitizeHTML"
+
+
+class MessageSinkRegistryTest(unittest.TestCase):
+ """The registry must account for every display-ready-sink call site."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.sites = _call_sites()
+
+ def test_the_four_sink_implementations_all_exist(self):
+ # If a file stops defining its own sink (e.g. it starts delegating to
+ # createUIStateManager) the registry's routing assumptions change and
+ # this guard must be re-derived rather than silently kept.
+ own = {
+ "custom-nodes-manager.js": ".cn-manager-status",
+ "model-manager.js": ".cmm-manager-status",
+ "node-usage-analyzer.js": ".nu-manager-status",
+ }
+ for name, selector in own.items():
+ source = (JS_DIR / name).read_text(encoding="utf-8")
+ self.assertIn("showStatus(msg, color)", source, "%s lost its own sink" % name)
+ self.assertIn(selector, source)
+ common = (JS_DIR / "common.js").read_text(encoding="utf-8")
+ self.assertIn("export function createUIStateManager(", common)
+ self.assertIn("showStatus: (msg, color)", common)
+
+ def test_every_caller_is_classified(self):
+ """FAIL-CLOSED: an unregistered call site breaks the build."""
+ observed = Counter((name, method, arg) for name, _, _, method, arg in self.sites
+ if method in DISPLAY_READY_METHODS)
+ duplicates = {
+ ('custom-nodes-manager.js', 'showStatus', '`Loading workflow usage analysis ...`);'),
+ ('node-usage-analyzer.js', 'showMessage',
+ '`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");'),
+ }
+ expected = Counter({key: 1 + (key in duplicates) for key in REGISTRY})
+ self.assertEqual(observed, expected, 'Changed message calls require a provenance review')
+
+ def test_the_observed_receivers_are_the_pinned_set(self):
+ observed = {receiver for _name, _lineno, receiver, _m, _a in self.sites}
+ self.assertEqual(
+ observed, set(PINNED_RECEIVERS),
+ "the set of receivers calling the message sinks changed: %s. These "
+ "sinks assign innerHTML and DO NOT escape, so every route to them "
+ "must be accounted for. Classify the new receiver's call sites in "
+ "REGISTRY and add it to PINNED_RECEIVERS — do not narrow CALL_RE to "
+ "make this pass." % (sorted(observed ^ set(PINNED_RECEIVERS)),),
+ )
+
+ def test_no_sink_method_is_destructured_out_of_its_receiver(self):
+ for name in SINK_FILES:
+ source = (JS_DIR / name).read_text(encoding="utf-8")
+ with self.subTest(file=name):
+ self.assertEqual(
+ DESTRUCTURE_RE.findall(source), [],
+ "%s destructures a message-sink method out of its receiver. "
+ "The resulting bare calls are invisible to this guard; call "
+ "them through their receiver instead." % (name,),
+ )
+
+ def test_unsupported_sink_calls_require_review(self):
+ source = (JS_DIR / "common.js").read_text(encoding="utf-8")
+ for call in ('this["showMessage"](raw);',
+ 'createUIStateManager(element).showError(raw);'):
+ with self.subTest(call=call), patch(__name__ + ".SINK_FILES", ["common.js"]):
+ with patch.object(Path, "read_text", return_value=source + "\n" + call):
+ with self.assertRaisesRegex(AssertionError, "Unsupported message-sink call"):
+ _call_sites()
+
+ def test_the_registry_has_no_stale_entries(self):
+ """A registry entry whose call site is gone or whose argument changed."""
+ live = {
+ (name, method, arg)
+ for name, _lineno, _receiver, method, arg in self.sites
+ if method in DISPLAY_READY_METHODS
+ }
+ stale = sorted(key for key in REGISTRY if key not in live)
+ self.assertEqual(
+ stale, [],
+ "REGISTRY entries no longer match any call site — the caller was removed "
+ "or its argument changed, so its provenance verdict is unverified:\n %s"
+ % "\n ".join("%s %s(%s" % key for key in stale),
+ )
+
+ def test_server_escaped_callers_do_not_escape_again(self):
+ for (name, method, arg), (provenance, reason) in REGISTRY.items():
+ if provenance != SERVER_ESCAPED:
+ continue
+ with self.subTest(file=name, method=method, arg=arg):
+ self.assertNotIn(
+ ESCAPE_MARKER, arg,
+ "%s %s escapes an ALREADY-escaped value — the user reads "
+ "'<Flux>' instead of '