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'{name}>' 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()