diff --git a/AppImage/scripts/notification_channels.py b/AppImage/scripts/notification_channels.py index ebcaa666..0f6b43c8 100644 --- a/AppImage/scripts/notification_channels.py +++ b/AppImage/scripts/notification_channels.py @@ -332,6 +332,7 @@ class TelegramChannel(NotificationChannel): r'&(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);|<[^<>]+>|.', re.DOTALL, ) + entity_re = re.compile(r'&(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);') tag_re = re.compile(r'<\s*(/?)\s*([A-Za-z0-9-]+)(?:\s[^<>]*)?>') void_tags = {'br'} @@ -352,19 +353,69 @@ class TelegramChannel(NotificationChannel): def _closers(stack): return ''.join(f'' for name, _ in reversed(stack)) + def _openers(stack): + return ''.join(opener for _, opener in stack) + + def _plain_chunks(tokens): + """Drop unsafe formatting while preserving safe visible HTML text.""" + safe_tokens = [] + for token in tokens: + if tag_re.fullmatch(token): + continue + if entity_re.fullmatch(token) and len(token) <= self.MAX_LENGTH: + safe_tokens.append(token) + continue + if entity_re.fullmatch(token) or token in {'&', '<', '>'}: + safe_tokens.extend(token_re.findall(self._escape_html(token))) + else: + safe_tokens.append(token) + + plain_chunks = [] + current = '' + for token in safe_tokens: + if current and len(current) + len(token) > self.MAX_LENGTH: + plain_chunks.append(current) + current = '' + current += token + if current: + plain_chunks.append(current) + return plain_chunks + + tokens = token_re.findall(text) + probe_tags = [] + unsafe_html = False + for token in tokens: + match = tag_re.fullmatch(token) + if match and match.group(1): + name = match.group(2).lower() + if not probe_tags or probe_tags[-1][0] != name: + unsafe_html = True + break + next_tags = _advance(probe_tags, token) + minimum_chunk = len(_openers(probe_tags)) + len(token) + len(_closers(next_tags)) + if len(token) > self.MAX_LENGTH or minimum_chunk > self.MAX_LENGTH: + unsafe_html = True + break + probe_tags = next_tags + + if unsafe_html: + return _plain_chunks(tokens) + chunks = [] current = '' open_tags = [] - for token in token_re.findall(text): + for token in tokens: next_tags = _advance(open_tags, token) if current and len(current) + len(token) + len(_closers(next_tags)) > self.MAX_LENGTH: chunks.append(current + _closers(open_tags)) - current = ''.join(opener for _, opener in open_tags) + current = _openers(open_tags) current += token open_tags = _advance(open_tags, token) if current: chunks.append(current + _closers(open_tags)) + if any(len(chunk) > self.MAX_LENGTH for chunk in chunks): + return _plain_chunks(tokens) return chunks @staticmethod diff --git a/AppImage/scripts/tests/test_vzdump_ai_integrity.py b/AppImage/scripts/tests/test_vzdump_ai_integrity.py index faf79382..458606bf 100644 --- a/AppImage/scripts/tests/test_vzdump_ai_integrity.py +++ b/AppImage/scripts/tests/test_vzdump_ai_integrity.py @@ -250,8 +250,8 @@ class VzdumpAIIntegrityTests(unittest.TestCase): rendered["body"], "INFO", data, ) - self.assertEqual(html.count("guest-100 (100)"), 1) - self.assertEqual(html.count("guest-148 (148)"), 1) + for vmid in range(100, 149): + self.assertEqual(html.count(f"guest-{vmid} ({vmid})"), 1, vmid) self.assertEqual(html.count("49 backups"), 1) def test_backup_fail_email_html_keeps_inventory_and_localized_status_once(self): @@ -275,12 +275,13 @@ class VzdumpAIIntegrityTests(unittest.TestCase): rendered["body"], "CRITICAL", data, ) - self.assertEqual(html.count("guest-100 (100)"), 1) - self.assertEqual(html.count("guest-148 (148)"), 1) + for vmid in range(100, 149): + self.assertEqual(html.count(f"guest-{vmid} ({vmid})"), 1, vmid) self.assertEqual(html.count("49 backups"), 1) self.assertEqual(html.count("1 failed"), 1) self.assertEqual(html.count(">Zlyhalo<"), 1) self.assertNotIn(">Failed<", html) + self.assertLessEqual(html.count("last guest failed"), 1) def test_telegram_chunks_preserve_complete_49_item_message(self): rendered = _render("backup_complete") @@ -330,6 +331,50 @@ class VzdumpAIIntegrityTests(unittest.TestCase): self.assertNotRegex(chunk, r"&(?:amp)?$") self.assertNotRegex(chunk, r"^amp;") + def test_telegram_chunks_bound_an_oversized_entity(self): + channel = TelegramChannel("123:token", "456") + chunks = channel._split_message("&" + ("entity" * 900) + ";") + + self.assertTrue(chunks) + self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks)) + + def test_telegram_chunks_bound_an_oversized_tag(self): + channel = TelegramChannel("123:token", "456") + html_message = 'visible text' + chunks = channel._split_message(html_message) + + self.assertTrue(chunks) + self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks)) + self.assertIn("visible text", "".join(chunks)) + + def test_telegram_chunks_bound_deeply_nested_formatting(self): + from html.parser import HTMLParser + + class _BalancedParser(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=False) + self.stack = [] + + def handle_starttag(self, tag, attrs): + self.stack.append(tag) + + def handle_endtag(self, tag): + if not self.stack or self.stack.pop() != tag: + raise AssertionError(f"unbalanced closing tag: {tag}") + + html_message = ("" * 700) + ("A" * 5000) + ("" * 700) + channel = TelegramChannel("123:token", "456") + chunks = channel._split_message(html_message) + + self.assertTrue(chunks) + self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks)) + self.assertEqual("".join(chunks).count("A"), 5000) + for chunk in chunks: + parser = _BalancedParser() + parser.feed(chunk) + parser.close() + self.assertEqual(parser.stack, []) + if __name__ == "__main__": unittest.main()