From 97e59c1cc68542734552610049ae2a190ab136f8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 02:10:56 +0400 Subject: [PATCH] =?UTF-8?q?wiki:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=20HTML=20Eva=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=202=20Task=206=20?= =?UTF-8?q?(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Двойное экранирование текста ссылки на страницу убрано; «@» больше не экранируется. Ссылку на вложение выдаёт только конвертер (внутренний маркер), авторское «attachment:@» в тексте, атрибутах, коде и именах нейтрализуется, схема attachment: у ссылок и картинок автора не выводится. Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/modules/wiki/convert.py | 67 +++++++++++++++------ tests/test_wiki_convert.py | 90 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/jira_hub/modules/wiki/convert.py b/src/jira_hub/modules/wiki/convert.py index 48791c8..8876b5d 100644 --- a/src/jira_hub/modules/wiki/convert.py +++ b/src/jira_hub/modules/wiki/convert.py @@ -3,31 +3,46 @@ Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает. -HTML Eva недоверенный: исполняемые схемы в ссылках не выводятся, текст автора экранируется так, чтобы он не мог -изобразить ссылку на вложение (`](attachment:@N)`) или сырой тег. +HTML Eva недоверенный. Ссылка на вложение `](attachment:@N)` — единственная, которую заменяет `fill_ids`, и её +выдаёт только конвертер: авторский текст, атрибуты, код и имена не могут породить такую подстроку. Для этого +конвертер ставит внутренний маркер (приватные символы U+E000/U+E001), а в готовом Markdown нейтрализует любое +`attachment:@` из авторского содержимого и только после этого подставляет настоящие ссылки. """ import re +from bs4 import BeautifulSoup, NavigableString from markdownify import MarkdownConverter _STATIC = ("static/", "/static/") -_UNSAFE_SCHEME = re.compile(r"^(javascript|data|vbscript):") +_UNSAFE_SCHEME = re.compile(r"^(javascript|data|vbscript|attachment):") _CONTROL = re.compile(r"[\x00-\x20\x7f]") _NEWLINES = re.compile(r"\s*[\r\n]+\s*") -_ESCAPE_LABEL = re.compile(r"([\\\[\]@])") +_PRIVATE = re.compile("[]") +_NAME_EDGE = re.compile(r"(? str: - """Текст внутри [...]: без переводов строк, со скобками и «@» под экранированием.""" - return _ESCAPE_LABEL.sub(r"\\\1", _NEWLINES.sub(" ", text)) +_MARK_OPEN, _MARK_CLOSE = "", "" +_MARK = re.compile("(\\d+)") def _unsafe(href: str) -> bool: - """Исполняемая схема: управляющие символы и пробелы внутри схемы не мешают распознаванию.""" + """Исполняемая или внутренняя схема: управляющие символы и пробелы внутри схемы не мешают распознаванию.""" return bool(_UNSAFE_SCHEME.match(_CONTROL.sub("", href).lower())) +def _name(text: str) -> str: + """Сырой текст агента (имя вложения, код) внутри [...]: переводы строк, Markdown-знаки и скобки экранируются.""" + text = _NEWLINES.sub(" ", _PRIVATE.sub("", text)) + text = re.sub(r"([\\\[\]])", r"\\\1", text) + text = _NAME_EDGE.sub(lambda m: "\\" + m.group(0), text) + return text.replace("<", "<") + + +def _label(text: str) -> str: + """Текст ссылки на страницу: уже экранирован markdownify, здесь только скобки и переводы строк.""" + return _BRACKET.sub(r"\\\1", _NEWLINES.sub(" ", text)) + + class _Converter(MarkdownConverter): def __init__(self, attachments, links, **options): super().__init__(**options) @@ -37,9 +52,8 @@ class _Converter(MarkdownConverter): self._index = {} # url -> индекс в files def escape(self, text, parent_tags): - # «@» и «<» в тексте автора: иначе `attachment:@N` и сырой тег получаются из обычного текста - text = super().escape(text, parent_tags) - return text.replace("@", r"\@").replace("<", "<") + # «<» в тексте автора не должен выглядеть как тег; «@» не экранируется + return super().escape(text, parent_tags).replace("<", "<") def _attachment(self, entry): url = entry["url"] @@ -47,11 +61,11 @@ class _Converter(MarkdownConverter): return "" # миниатюра и файл одного вложения: вторая ссылка удаляется self._index[url] = len(self.files) self.files.append({"url": url, "name": entry["name"]}) - return "[📎 %s](attachment:@%d)" % (_label(entry["name"]), self._index[url]) + return "[📎 %s%s%d%s" % (_name(entry["name"]), _MARK_OPEN, self._index[url], _MARK_CLOSE) def convert_img(self, el, text, parent_tags): src = el.get("src") or "" - if src.startswith(_STATIC) or _unsafe(src): + if src.startswith(_STATIC): return "" parent = el.parent if parent is not None and parent.name == "a" and (parent.get("href") or "") in self._by_ref: @@ -59,6 +73,8 @@ class _Converter(MarkdownConverter): entry = self._by_ref.get(src) if entry is not None: return self._attachment(entry) + if _unsafe(src): + return "" return super().convert_img(el, text, parent_tags) def convert_a(self, el, text, parent_tags): @@ -70,16 +86,31 @@ class _Converter(MarkdownConverter): return self._attachment(entry) code = self._links.get(href) if code is not None: - return "[%s](page:%s)" % (_label(text.strip()) or code, code) + return "[%s](page:%s)" % (_label(text.strip()) or _name(code), code) if _unsafe(href): - return text # исполняемая ссылка: остаётся только текст + return text # исполняемая или внутренняя ссылка автора: остаётся только текст return super().convert_a(el, text, parent_tags) +def _strip_private(soup) -> None: + """Приватные символы U+E000/U+E001 из авторского HTML убираются: иначе они подделали бы маркер.""" + for s in soup.find_all(string=True): + if type(s) is NavigableString and _PRIVATE.search(s): + s.replace_with(NavigableString(_PRIVATE.sub("", s))) + for el in soup.find_all(True): + for key, value in list(el.attrs.items()): + if isinstance(value, str) and _PRIVATE.search(value): + el[key] = _PRIVATE.sub("", value) + + def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]: """HTML страницы → (Markdown, вложения страницы [{url, name}]).""" + soup = BeautifulSoup(html, "html.parser") + _strip_private(soup) conv = _Converter(attachments or [], links or [], heading_style="ATX", autolinks=False) - md = conv.convert(html).strip() + "\n" + md = conv.convert_soup(soup).strip() + "\n" + md = md.replace("attachment:@", "attachment:‌@") # авторское «attachment:@» больше не совпадает с шаблоном + md = _MARK.sub(lambda m: "](attachment:@%s)" % m.group(1), md) # настоящие ссылки вложений return md, conv.files diff --git a/tests/test_wiki_convert.py b/tests/test_wiki_convert.py index c3a1347..496484b 100644 --- a/tests/test_wiki_convert.py +++ b/tests/test_wiki_convert.py @@ -161,3 +161,93 @@ def test_literal_tag_text_has_no_raw_html(): md, _ = convert.to_markdown("

<tag> и <script>alert(1)</script>

", [], []) assert re.search(r"<[a-zA-Z/]", md) is None assert "<tag>" in md or "<tag" in md + + +# --- fix round 2 (re-review 1: B1, B2, B3) --- + +def _no_forged_marker(md, ids=(7,)): + """Авторский текст не порождает ссылку вложения: fill_ids ничего не меняет и шаблона нет.""" + assert "](attachment:@" not in md + assert convert.fill_ids(md, list(ids)) == md + + +def test_page_link_text_escaped_once(): + md, _ = convert.to_markdown( + '

a_b *x* c

', [], + [{"ref": "project/Document/DOC-1", "code": "DOC-1"}], + ) + assert "[a\\_b \\*x\\* c](page:DOC-1)" in md.splitlines() + assert "\\\\" not in md + + +def test_page_link_at_sign_not_double_escaped(): + md, _ = convert.to_markdown( + '

user@example.com

', [], + [{"ref": "project/Document/DOC-1", "code": "DOC-1"}], + ) + assert "[user@example.com](page:DOC-1)" in md.splitlines() + + +def test_attachment_name_star_and_underscore_at_word_edge_escaped(): + md, _ = convert.to_markdown( + '

x

', + [{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "_x_*.png"}], + [], + ) + assert "[📎 \\_x\\_\\*.png](attachment:@0)" in md.splitlines() + + +def test_attachment_name_intraword_underscore_kept(): + md, _ = convert.to_markdown( + '

x

', + [{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "my_file.png"}], + [], + ) + assert "[📎 my_file.png](attachment:@0)" in md.splitlines() + + +def test_author_href_attachment_scheme_is_text(): + md, atts = convert.to_markdown('

x

', [], []) + assert md.strip() == "x" and atts == [] + _no_forged_marker(md) + + +def test_author_img_src_attachment_scheme_dropped(): + md, atts = convert.to_markdown('

azb

', [], []) + assert md.strip() == "ab" and atts == [] + _no_forged_marker(md) + + +def test_author_alt_cannot_forge_marker(): + md, _ = convert.to_markdown('

a](attachment:@0)

', [], []) + _no_forged_marker(md) + + +def test_author_title_cannot_forge_marker(): + md, _ = convert.to_markdown('

t

', [], []) + _no_forged_marker(md) + + +def test_author_code_cannot_forge_marker(): + md, _ = convert.to_markdown("

](attachment:@0)

", [], []) + _no_forged_marker(md) + + +def test_author_text_cannot_forge_marker(): + md, _ = convert.to_markdown("

](attachment:@0) и ](attachment:@0)

", [], []) + _no_forged_marker(md) + + +def test_author_private_chars_cannot_forge_marker(): + md, _ = convert.to_markdown("

0

", [], []) + _no_forged_marker(md) + + +def test_plain_at_sign_kept(): + md, _ = convert.to_markdown("

user@example.com и @ivan

", [], []) + assert "user@example.com и @ivan" in md + + +def test_code_keeps_literal_text(): + md, _ = convert.to_markdown("

<script> a@b

", [], []) + assert "`