From 74f8fab5461ad54ffc803659f754a28edaeb883e Mon Sep 17 00:00:00 2001 From: AnubhavSolanki Date: Mon, 20 Jul 2026 05:00:25 +0200 Subject: [PATCH 1/3] feat(jira): convert {info}/{note}/{warning}/{tip}/{expand} macros to Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jira_to_markdown only handled {panel}; the admonition macros ({info}, {note}, {warning}, {tip}) and the {expand} collapsible block leaked through as raw {macro}...{macro} wiki markup. Convert them read-only (Jira -> Markdown), consistent with {panel}: - Admonitions render as a bold labelled heading keeping the macro type, e.g. **ℹ️ Info: ** (or **ℹ️ Info** with no title), then the body. - {expand} renders as **▸ Expand: <title>** (or **▸ Expand**); the {expand:Foo} shorthand and {expand:title=Foo} both yield the title, while option-only params such as {expand:macro-id=123} render as plain **▸ Expand** without exposing the internal option. - Titles are HTML-escaped so angle brackets / HTML-looking text survive the HTML->markdown pass literally instead of being reparsed as tags. - Generated headings are wrapped in a private sentinel so clean_jira_text protects only those headings from the HTML pass; user-authored bold text that merely starts with a macro label is still sanitized normally. - Nested and same-type-nested macros are handled by a stack-based inside-out scan; inner code blocks are preserved via placeholder protection. Conversion is one-directional by design; write-back (Markdown -> Jira) is out of scope and can be a follow-up PR. Github-Issue: #1503 --- src/mcp_atlassian/preprocessing/jira.py | 163 ++++++++++- .../unit/preprocessing/test_preprocessing.py | 267 ++++++++++++++++++ 2 files changed, 427 insertions(+), 3 deletions(-) diff --git a/src/mcp_atlassian/preprocessing/jira.py b/src/mcp_atlassian/preprocessing/jira.py index 31d422936..844412adb 100644 --- a/src/mcp_atlassian/preprocessing/jira.py +++ b/src/mcp_atlassian/preprocessing/jira.py @@ -1,5 +1,6 @@ """Jira-specific text preprocessing module.""" +import html import logging import re from typing import Any @@ -24,6 +25,130 @@ def _convert_panel(params: str | None, content: str) -> str: return f"\n{content}\n" +# Jira admonition macros ({info}, {note}, {warning}, {tip}) share the {panel} +# param form (title=...). The macro type carries meaning (info vs warning), so +# it is kept as a bold label prefix on the converted heading. Conversion is +# read-only (Jira -> Markdown), matching how {panel} is handled. +_ADMONITION_LABELS = { + "info": "ℹ️ Info", + "note": "📝 Note", + "warning": "⚠️ Warning", + "tip": "💡 Tip", +} + +# Label for the {expand} collapsible macro; the ▸ marks it as expandable. +_EXPAND_LABEL = "▸ Expand" + +# Private sentinel wrapping a heading generated from a Jira macro. It lets +# clean_jira_text protect only these headings from the HTML->markdown pass +# (see _protect_macro_headings) without matching user-authored bold text that +# merely happens to start with the same label. NUL bytes cannot occur in +# normal document text; the sentinel is stripped after the HTML pass. +_MACRO_HEADING_MARK = "\x00JMH\x00" + + +def _macro_heading(heading: str, content: str) -> str: + """Render a macro as a bold heading wrapped in the protection sentinel.""" + content = content.strip() + return f"\n{_MACRO_HEADING_MARK}**{heading}**{_MACRO_HEADING_MARK}\n{content}\n" + + +def _convert_admonition(macro: str, params: str | None, content: str) -> str: + """Convert a Jira {info}/{note}/{warning}/{tip} block to markdown. + + Emits a bold labelled heading followed by the body, e.g. + ``**ℹ️ Info: <title>**\\n<content>`` (or ``**ℹ️ Info**`` when the macro has + no title). Mirrors the read-only handling of {panel}. The title is + HTML-escaped so angle brackets / HTML-looking text survive the downstream + HTML->markdown pass literally instead of being reparsed as tags. + """ + label = _ADMONITION_LABELS[macro] + title = "" + if params: + title_match = re.search(r"title=([^|}]+)", params) + if title_match: + title = title_match.group(1).strip() + title = html.escape(title, quote=False) + heading = f"{label}: {title}" if title else label + return _macro_heading(heading, content) + + +def _convert_expand(title: str | None, content: str) -> str: + """Convert a Jira {expand} collapsible block to markdown. + + Emits ``**▸ Expand: <title>**\\n<content>`` (or ``**▸ Expand**`` when there + is no title). ``{expand:Foo}`` and ``{expand:title=Foo}`` both yield + ``Foo``; option-only params such as ``{expand:macro-id=123}`` carry no title + and render as plain ``▸ Expand`` rather than exposing the internal option. + """ + raw = (title or "").strip() + title_match = re.search(r"(?:^|\|)title=([^|}]+)", raw) + if title_match: + summary = title_match.group(1).strip() + elif raw and "=" not in raw.split("|", 1)[0]: + # Shorthand {expand:Foo}: first segment is a title only when it is not + # a key=value option (an internal parameter). + summary = raw.split("|", 1)[0].strip() + else: + summary = "" + summary = html.escape(summary, quote=False) + heading = f"{_EXPAND_LABEL}: {summary}" if summary else _EXPAND_LABEL + return _macro_heading(heading, content) + + +def _convert_macro_blocks(text: str) -> str: + """Convert nested Jira admonition and expand macros to Markdown. + + Jira uses the same bare tag for a macro's opening and closing delimiter. + A stack identifies the matching close for each supported macro, and the + innermost complete block is replaced first so nested macros retain their + boundaries while their parent is converted. + """ + macro_tag_pattern = re.compile(r"\{(info|note|warning|tip|expand)(?::([^{}]*))?\}") + + while True: + stack: list[tuple[int, int, str, str | None, bool]] = [] + replacement: tuple[int, int, str, str | None, str] | None = None + + for match in macro_tag_pattern.finditer(text): + macro = match.group(1) + params = match.group(2) + + # Closing tags have no parameters and must match the macro at the + # top of the stack. A parameterized tag is always an opening tag, + # which also disambiguates same-type nested macros. + is_closing = bool(params is None and stack and stack[-1][2] == macro) + if not is_closing: + if stack: + start, opening_end, open_macro, open_params, _ = stack[-1] + stack[-1] = (start, opening_end, open_macro, open_params, True) + stack.append((match.start(), match.end(), macro, params, False)) + continue + + start, opening_end, open_macro, open_params, has_nested = stack.pop() + if has_nested: + continue + + replacement = ( + start, + match.end(), + open_macro, + open_params, + text[opening_end : match.start()], + ) + break + + if replacement is None: + return text + + start, end, macro, params, content = replacement + if macro == "expand": + converted = _convert_expand(params, content) + else: + converted = _convert_admonition(macro, params, content) + text = text[:start] + converted + text[end:] + + class JiraPreprocessor(BasePreprocessor): """Handles text preprocessing for Jira content.""" @@ -142,11 +267,29 @@ def clean_jira_text(self, text: str) -> str: # Convert markup only if translation is enabled if not self.disable_translation: - # First convert any Jira markup to Markdown - text = self.jira_to_markdown(text) + # First convert any Jira markup to Markdown, keeping the macro + # heading sentinels so they can be protected below. + text = self.jira_to_markdown(text, _keep_macro_marks=True) + + # Protect headings generated from Jira macros from the HTML + # conversion pass. The macro converters wrap each generated heading + # in a private sentinel, so only those headings are protected — + # user-authored bold text that merely starts with the same label is + # left for _convert_html_to_markdown to sanitize like any other + # content. The sentinel is stripped as the heading is stored. + macro_headings: list[str] = [] + mark = re.escape(_MACRO_HEADING_MARK) + text = _extract_blocks( + text, + rf"{mark}(\*\*[^\n]*?\*\*){mark}", + lambda match: match.group(1), + macro_headings, + "JIRAMACRO", + ) # Then convert any remaining HTML to markdown text = self._convert_html_to_markdown(text) + text = _restore_blocks(text, macro_headings, "JIRAMACRO") return text.strip() @@ -208,7 +351,9 @@ def _process_smart_links(self, text: str) -> str: return text - def jira_to_markdown(self, input_text: str) -> str: + def jira_to_markdown( + self, input_text: str, *, _keep_macro_marks: bool = False + ) -> str: """ Convert Jira markup to Markdown format. @@ -336,6 +481,13 @@ def _jira_code_to_md(match: re.Match[str]) -> str: flags=re.MULTILINE, ) + # Admonition and expand blocks -> bold labelled heading. A stack-based + # inside-out scan (see _convert_macro_blocks) handles nested same-type + # blocks — {expand:Outer}{expand:Inner}..{expand}..{expand} or + # {info}..{info}..{info} — so they close at the correct boundary instead + # of at the first inner tag, matching Atlassian's nested-macro semantics. + output = _convert_macro_blocks(output) + # Images with alt text output = re.sub( r"!([^|\n\s]+)\|([^\n!]*)alt=([^\n!\,]+?)" @@ -388,6 +540,11 @@ def _jira_code_to_md(match: re.Match[str]) -> str: output = _restore_blocks(output, code_blocks, "CODEBLOCK") output = _restore_blocks(output, inline_codes, "INLINECODE") + # The macro-heading sentinel only bridges to clean_jira_text's HTML + # pass; strip it here so direct callers get clean Markdown headings. + if not _keep_macro_marks: + output = output.replace(_MACRO_HEADING_MARK, "") + return output def _normalize_code_language(self, lang: str | None) -> str | None: diff --git a/tests/unit/preprocessing/test_preprocessing.py b/tests/unit/preprocessing/test_preprocessing.py index 7d2e9032e..6d9e28831 100644 --- a/tests/unit/preprocessing/test_preprocessing.py +++ b/tests/unit/preprocessing/test_preprocessing.py @@ -1420,6 +1420,273 @@ def test_bare_link_without_panel(self, preprocessor): assert "https://example.com" in result, f"URL dropped: {result}" +# {info}/{note}/{warning}/{tip} admonitions and {expand} collapsible toggles + + +class TestAdmonitionAndExpandBlocks: + """Tests for read-only {info}/{note}/{warning}/{tip} and {expand} conversion.""" + + @pytest.fixture + def preprocessor(self): + return JiraPreprocessor(base_url="https://example.atlassian.net") + + @pytest.mark.parametrize( + "test_id, input_text, expected_present, expected_absent", + [ + pytest.param( + "info-with-title", + "{info:title=AUTO-GENERATED METADATA}do not edit{info}", + ["**ℹ️ Info: AUTO-GENERATED METADATA**", "do not edit"], + ["{info"], + id="info-with-title", + ), + pytest.param( + "info-no-title", + "{info}some content{info}", + ["**ℹ️ Info**", "some content"], + ["{info"], + id="info-no-title", + ), + pytest.param( + "note-block", + "{note}heads up{note}", + ["**📝 Note**", "heads up"], + ["{note"], + id="note-block", + ), + pytest.param( + "warning-block", + "{warning}be careful{warning}", + ["**⚠️ Warning**", "be careful"], + ["{warning"], + id="warning-block", + ), + pytest.param( + "tip-block", + "{tip}pro tip{tip}", + ["**💡 Tip**", "pro tip"], + ["{tip"], + id="tip-block", + ), + pytest.param( + "info-multiline", + "{info:title=Notes}line one\nline two{info}", + ["**ℹ️ Info: Notes**", "line one", "line two"], + ["{info"], + id="info-multiline", + ), + pytest.param( + "adjacent-admonitions-distinct", + "{info}a{info}\n{warning}b{warning}", + ["**ℹ️ Info**", "**⚠️ Warning**", "a", "b"], + ["{info", "{warning"], + id="adjacent-admonitions-distinct", + ), + pytest.param( + "expand-with-title", + "{expand:Security MetaData}secret stuff{expand}", + ["**▸ Expand: Security MetaData**", "secret stuff"], + ["{expand", "<details"], + id="expand-with-title", + ), + pytest.param( + "expand-title-param-form", + "{expand:title=Details Here}body{expand}", + ["**▸ Expand: Details Here**", "body"], + ["{expand", "title=", "<details"], + id="expand-title-param-form", + ), + pytest.param( + "expand-title-param-with-extra-options", + "{expand:macro-id=123|title=Details Here}body{expand}", + ["**▸ Expand: Details Here**", "body"], + ["{expand", "macro-id=123", "<details"], + id="expand-title-param-with-extra-options", + ), + pytest.param( + "expand-option-only", + "{expand:macro-id=123}body{expand}", + ["**▸ Expand**", "body"], + ["{expand", "macro-id", "<details"], + id="expand-option-only", + ), + pytest.param( + "expand-no-title", + "{expand}collapsed body{expand}", + ["**▸ Expand**", "collapsed body"], + ["{expand", "<details"], + id="expand-no-title", + ), + ], + ) + def test_macro_conversion( + self, + preprocessor, + test_id: str, + input_text: str, + expected_present: list[str], + expected_absent: list[str], + ): + """{info}/{note}/{warning}/{tip} and {expand} convert to bold headings.""" + result = preprocessor.jira_to_markdown(input_text) + for expected in expected_present: + assert expected in result, f"[{test_id}] Expected '{expected}' in: {result}" + for absent in expected_absent: + assert absent not in result, ( + f"[{test_id}] Unexpected '{absent}' in: {result}" + ) + + def test_expand_preserves_inner_code_block(self, preprocessor): + """Code inside {expand} survives as a fenced block (the screenshot case).""" + input_text = ( + '{expand:Security MetaData}{code:json}{"kind": "not-fixable"}{code}{expand}' + ) + result = preprocessor.jira_to_markdown(input_text) + assert "**▸ Expand: Security MetaData**" in result, result + assert "```json" in result, f"code fence lost inside expand: {result}" + assert "not-fixable" in result, result + assert "{expand" not in result and "{code" not in result, result + + def test_macro_survives_clean_jira_text(self, preprocessor): + """The bold heading survives the full fetch path (jira->md->html->md). + + clean_jira_text runs jira_to_markdown then _convert_html_to_markdown; + plain bold markdown passes through the markdownify step unchanged. + """ + result = preprocessor.clean_jira_text( + "{info:title=AUTO-GENERATED METADATA}do not edit{info}" + ) + assert "ℹ️ Info: AUTO-GENERATED METADATA" in result, result + assert "do not edit" in result, result + assert "{info" not in result, result + + @pytest.mark.parametrize( + "input_text, expected_summaries", + [ + pytest.param( + "{expand:Outer}{expand:Inner}inside{expand}after{expand}", + ["▸ Expand: Outer", "▸ Expand: Inner"], + id="same-type-expand", + ), + pytest.param( + "{info:title=A}{info:title=B}deep{info}tail{info}", + ["ℹ️ Info: A", "ℹ️ Info: B"], + id="same-type-info", + ), + pytest.param( + "{expand:L1}{expand:L2}{info}x{info}mid{expand}end{expand}", + ["▸ Expand: L1", "▸ Expand: L2", "ℹ️ Info"], + id="three-level-mixed", + ), + pytest.param( + "{expand:More}{info:title=Context}inside{info}{expand}", + ["▸ Expand: More", "ℹ️ Info: Context"], + id="cross-type-nesting", + ), + ], + ) + def test_nested_macros_convert_without_leaking_tags( + self, preprocessor, input_text: str, expected_summaries: list[str] + ): + """Nested (including same-type) macros fully convert with no raw tags.""" + result = preprocessor.jira_to_markdown(input_text) + for summary in expected_summaries: + assert f"**{summary}**" in result, f"missing '{summary}' in: {result}" + for tag in ("{info", "{note", "{warning", "{tip", "{expand"): + assert tag not in result, f"raw '{tag}' leaked: {result}" + + @pytest.mark.parametrize( + "input_text, expected", + [ + pytest.param( + "{expand:Outer}before{info:title=Inner}inside{info}after{expand}", + "**▸ Expand: Outer**\nbefore\n**ℹ️ Info: Inner**\ninside\nafter", + id="surrounding-prose", + ), + pytest.param( + "{expand:Outer}{info:title=First}one{info}" + "{warning}two{warning}{expand}", + "**▸ Expand: Outer**\n**ℹ️ Info: First**\none\n\n**⚠️ Warning**\ntwo", + id="adjacent-nested-blocks", + ), + ], + ) + def test_nested_macro_block_boundaries_survive_clean_path( + self, preprocessor, input_text: str, expected: str + ): + """The full Jira fetch path preserves nested Markdown block separators.""" + assert preprocessor.clean_jira_text(input_text) == expected + + def test_multiline_admonition_body_preserved(self, preprocessor): + """A multi-paragraph body is kept intact under the heading.""" + result = preprocessor.jira_to_markdown( + "{info:title=Context}first paragraph\n\nsecond paragraph{info}" + ) + assert "**ℹ️ Info: Context**" in result, result + assert "first paragraph" in result and "second paragraph" in result, result + assert "{info" not in result, result + + @pytest.mark.parametrize( + ("jira_markup", "expected"), + [ + ( + "{info:title=Use <summary> tag}<p>body</p>{info}", + "**ℹ️ Info: Use <summary> tag**\n\nbody", + ), + ( + "{info:title=<b>bold</b>}<p>body</p>{info}", + "**ℹ️ Info: <b>bold</b>**\n\nbody", + ), + ( + "{warning:title=2 < 3 and 4 > 1}<p>x</p>{warning}", + "**⚠️ Warning: 2 < 3 and 4 > 1**\n\nx", + ), + ( + "{expand:a < b > c}<p>body</p>{expand}", + "**▸ Expand: a < b > c**\n\nbody", + ), + ], + ) + def test_html_looking_title_survives_clean_path( + self, preprocessor, jira_markup, expected + ): + """Angle brackets / HTML in a title survive the production read path. + + clean_jira_text runs jira_to_markdown then _convert_html_to_markdown; + the generated heading is protected while markdownify handles unrelated + HTML, so both the title text and its Markdown formatting are preserved. + """ + assert preprocessor.clean_jira_text(jira_markup) == expected + + def test_user_authored_matching_bold_line_is_sanitized(self, preprocessor): + """A user-authored bold line that mimics a macro heading is NOT exempt. + + Heading protection is scoped to headings generated from Jira macros + (via a private sentinel), so ordinary text that merely starts with a + macro label still flows through _convert_html_to_markdown — its HTML + is stripped/converted rather than passed through raw. + """ + result = preprocessor.clean_jira_text( + "*▸ Expand: <script>alert(1)</script>*<p>body</p>" + ) + assert "<script>" not in result, result + assert "alert(1)" not in result, result # script tag + payload stripped + assert "body" in result, result + + def test_generated_heading_survives_body_html(self, preprocessor): + """A genuine macro heading keeps its bold markers when the body has HTML.""" + result = preprocessor.clean_jira_text( + "{info:title=Ctx}see <b>this</b> and more{info}" + ) + assert "**ℹ️ Info: Ctx**" in result, result + assert "**this**" in result, result # body HTML converted to Markdown + assert "<b>" not in result, result + + def test_read_only_leaves_fenced_code_write_back_unchanged(self, preprocessor): + """Read-only scope must not alter Markdown->Jira fenced-code behaviour.""" + assert preprocessor.markdown_to_jira("```\nline\n```") == "{code}line\n{code}" + + # Code block placeholder protection tests From 6d2d164db465f1fb3195d9ea090369989a36c59b Mon Sep 17 00:00:00 2001 From: "mcp-atlassian-maintainer[bot]" <300134992+mcp-atlassian-maintainer[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:09:13 +0200 Subject: [PATCH 2/3] test(jira): cover macro heading HTML protection Github-Issue: #1502 --- .../unit/preprocessing/test_preprocessing.py | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/tests/unit/preprocessing/test_preprocessing.py b/tests/unit/preprocessing/test_preprocessing.py index 6d9e28831..17dff1845 100644 --- a/tests/unit/preprocessing/test_preprocessing.py +++ b/tests/unit/preprocessing/test_preprocessing.py @@ -1658,19 +1658,43 @@ def test_html_looking_title_survives_clean_path( """ assert preprocessor.clean_jira_text(jira_markup) == expected - def test_user_authored_matching_bold_line_is_sanitized(self, preprocessor): - """A user-authored bold line that mimics a macro heading is NOT exempt. + @pytest.mark.parametrize( + ("label", "html_markup", "expected"), + [ + ("▸ Expand", "<b>bold</b>", "**bold**"), + ( + "▸ Expand", + '<a href="https://example.com">link</a>', + "[link](https://example.com)", + ), + ("▸ Expand", "<script>alert(1)</script>", None), + ("ℹ️ Info", "<b>bold</b>", "**bold**"), + ( + "ℹ️ Info", + '<a href="https://example.com">link</a>', + "[link](https://example.com)", + ), + ("ℹ️ Info", "<script>alert(1)</script>", None), + ], + ) + def test_user_authored_matching_bold_line_uses_html_conversion( + self, preprocessor, label, html_markup, expected + ): + """Matching user text isn't protected as a generated macro heading. - Heading protection is scoped to headings generated from Jira macros - (via a private sentinel), so ordinary text that merely starts with a - macro label still flows through _convert_html_to_markdown — its HTML - is stripped/converted rather than passed through raw. + Only headings emitted by ``_convert_macro_blocks`` are protected from + the HTML pass. Ordinary bold Jira text with the same visible prefix + must still convert or remove nested HTML through that existing path. """ - result = preprocessor.clean_jira_text( - "*▸ Expand: <script>alert(1)</script>*<p>body</p>" - ) - assert "<script>" not in result, result - assert "alert(1)" not in result, result # script tag + payload stripped + result = preprocessor.clean_jira_text(f"*{label}: {html_markup}*<p>body</p>") + + assert "<b>" not in result and "</b>" not in result, result + assert "<a " not in result and "</a>" not in result, result + assert "<script>" not in result and "</script>" not in result, result + if expected is None: + assert "alert(1)" not in result, result + else: + assert expected in result, result assert "body" in result, result def test_generated_heading_survives_body_html(self, preprocessor): From 96e74ee66ae3448d845667209cd0f0de60ba9b97 Mon Sep 17 00:00:00 2001 From: "mcp-atlassian-maintainer[bot]" <300134992+mcp-atlassian-maintainer[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:04:00 +0200 Subject: [PATCH 3/3] fix(jira): harden macro heading protection Reported-by: AnubhavSolanki --- src/mcp_atlassian/preprocessing/jira.py | 96 +++++++++++++------ .../unit/preprocessing/test_preprocessing.py | 36 +++++++ 2 files changed, 101 insertions(+), 31 deletions(-) diff --git a/src/mcp_atlassian/preprocessing/jira.py b/src/mcp_atlassian/preprocessing/jira.py index 844412adb..b45db04dc 100644 --- a/src/mcp_atlassian/preprocessing/jira.py +++ b/src/mcp_atlassian/preprocessing/jira.py @@ -3,6 +3,7 @@ import html import logging import re +import uuid from typing import Any from .base import BasePreprocessor, _extract_blocks, _restore_blocks @@ -39,21 +40,40 @@ def _convert_panel(params: str | None, content: str) -> str: # Label for the {expand} collapsible macro; the ▸ marks it as expandable. _EXPAND_LABEL = "▸ Expand" -# Private sentinel wrapping a heading generated from a Jira macro. It lets -# clean_jira_text protect only these headings from the HTML->markdown pass -# (see _protect_macro_headings) without matching user-authored bold text that -# merely happens to start with the same label. NUL bytes cannot occur in -# normal document text; the sentinel is stripped after the HTML pass. -_MACRO_HEADING_MARK = "\x00JMH\x00" +# Template for the temporary sentinel wrapping a heading generated from a Jira +# macro. The UUID is selected per input so user-authored text cannot forge the +# exact marker and bypass the HTML->markdown pass. +_MACRO_HEADING_MARK = "\x00JMH:{}\x00" -def _macro_heading(heading: str, content: str) -> str: - """Render a macro as a bold heading wrapped in the protection sentinel.""" +def _new_macro_heading_mark(text: str) -> str: + """Return a temporary marker that cannot occur in the source text.""" + while True: + marker = _MACRO_HEADING_MARK.format(uuid.uuid4().hex) + if marker not in text: + return marker + + +def _normalize_macro_title(title: str) -> str: + """Normalize whitespace so a macro title remains one Markdown line.""" + return re.sub(r"\s+", " ", title).strip() + + +def _macro_heading( + heading: str, content: str, macro_heading_mark: str | None = None +) -> str: + """Render a macro as a bold heading with optional protection.""" content = content.strip() - return f"\n{_MACRO_HEADING_MARK}**{heading}**{_MACRO_HEADING_MARK}\n{content}\n" + marker = macro_heading_mark or "" + return f"\n{marker}**{heading}**{marker}\n{content}\n" -def _convert_admonition(macro: str, params: str | None, content: str) -> str: +def _convert_admonition( + macro: str, + params: str | None, + content: str, + macro_heading_mark: str | None = None, +) -> str: """Convert a Jira {info}/{note}/{warning}/{tip} block to markdown. Emits a bold labelled heading followed by the body, e.g. @@ -67,13 +87,16 @@ def _convert_admonition(macro: str, params: str | None, content: str) -> str: if params: title_match = re.search(r"title=([^|}]+)", params) if title_match: - title = title_match.group(1).strip() + title = title_match.group(1) + title = _normalize_macro_title(title) title = html.escape(title, quote=False) heading = f"{label}: {title}" if title else label - return _macro_heading(heading, content) + return _macro_heading(heading, content, macro_heading_mark) -def _convert_expand(title: str | None, content: str) -> str: +def _convert_expand( + title: str | None, content: str, macro_heading_mark: str | None = None +) -> str: """Convert a Jira {expand} collapsible block to markdown. Emits ``**▸ Expand: <title>**\\n<content>`` (or ``**▸ Expand**`` when there @@ -84,19 +107,20 @@ def _convert_expand(title: str | None, content: str) -> str: raw = (title or "").strip() title_match = re.search(r"(?:^|\|)title=([^|}]+)", raw) if title_match: - summary = title_match.group(1).strip() + summary = title_match.group(1) elif raw and "=" not in raw.split("|", 1)[0]: # Shorthand {expand:Foo}: first segment is a title only when it is not # a key=value option (an internal parameter). - summary = raw.split("|", 1)[0].strip() + summary = raw.split("|", 1)[0] else: summary = "" + summary = _normalize_macro_title(summary) summary = html.escape(summary, quote=False) heading = f"{_EXPAND_LABEL}: {summary}" if summary else _EXPAND_LABEL - return _macro_heading(heading, content) + return _macro_heading(heading, content, macro_heading_mark) -def _convert_macro_blocks(text: str) -> str: +def _convert_macro_blocks(text: str, macro_heading_mark: str | None = None) -> str: """Convert nested Jira admonition and expand macros to Markdown. Jira uses the same bare tag for a macro's opening and closing delimiter. @@ -143,9 +167,9 @@ def _convert_macro_blocks(text: str) -> str: start, end, macro, params, content = replacement if macro == "expand": - converted = _convert_expand(params, content) + converted = _convert_expand(params, content, macro_heading_mark) else: - converted = _convert_admonition(macro, params, content) + converted = _convert_admonition(macro, params, content, macro_heading_mark) text = text[:start] + converted + text[end:] @@ -267,9 +291,14 @@ def clean_jira_text(self, text: str) -> str: # Convert markup only if translation is enabled if not self.disable_translation: - # First convert any Jira markup to Markdown, keeping the macro - # heading sentinels so they can be protected below. - text = self.jira_to_markdown(text, _keep_macro_marks=True) + # First convert any Jira markup to Markdown, keeping per-input + # macro heading sentinels so they can be protected below. + macro_heading_mark = _new_macro_heading_mark(text) + text = self.jira_to_markdown( + text, + _keep_macro_marks=True, + _macro_heading_mark=macro_heading_mark, + ) # Protect headings generated from Jira macros from the HTML # conversion pass. The macro converters wrap each generated heading @@ -278,13 +307,14 @@ def clean_jira_text(self, text: str) -> str: # left for _convert_html_to_markdown to sanitize like any other # content. The sentinel is stripped as the heading is stored. macro_headings: list[str] = [] - mark = re.escape(_MACRO_HEADING_MARK) + mark = re.escape(macro_heading_mark) text = _extract_blocks( text, - rf"{mark}(\*\*[^\n]*?\*\*){mark}", + rf"{mark}(.*?){mark}", lambda match: match.group(1), macro_headings, "JIRAMACRO", + flags=re.DOTALL, ) # Then convert any remaining HTML to markdown @@ -352,7 +382,11 @@ def _process_smart_links(self, text: str) -> str: return text def jira_to_markdown( - self, input_text: str, *, _keep_macro_marks: bool = False + self, + input_text: str, + *, + _keep_macro_marks: bool = False, + _macro_heading_mark: str | None = None, ) -> str: """ Convert Jira markup to Markdown format. @@ -370,6 +404,11 @@ def jira_to_markdown( return input_text output = input_text + macro_heading_mark = None + if _keep_macro_marks: + macro_heading_mark = _macro_heading_mark or _new_macro_heading_mark( + input_text + ) # Protect code/noformat/inline-code blocks from downstream # transformations by replacing them with placeholders. @@ -486,7 +525,7 @@ def _jira_code_to_md(match: re.Match[str]) -> str: # blocks — {expand:Outer}{expand:Inner}..{expand}..{expand} or # {info}..{info}..{info} — so they close at the correct boundary instead # of at the first inner tag, matching Atlassian's nested-macro semantics. - output = _convert_macro_blocks(output) + output = _convert_macro_blocks(output, macro_heading_mark) # Images with alt text output = re.sub( @@ -540,11 +579,6 @@ def _jira_code_to_md(match: re.Match[str]) -> str: output = _restore_blocks(output, code_blocks, "CODEBLOCK") output = _restore_blocks(output, inline_codes, "INLINECODE") - # The macro-heading sentinel only bridges to clean_jira_text's HTML - # pass; strip it here so direct callers get clean Markdown headings. - if not _keep_macro_marks: - output = output.replace(_MACRO_HEADING_MARK, "") - return output def _normalize_code_language(self, lang: str | None) -> str | None: diff --git a/tests/unit/preprocessing/test_preprocessing.py b/tests/unit/preprocessing/test_preprocessing.py index 17dff1845..541742f5a 100644 --- a/tests/unit/preprocessing/test_preprocessing.py +++ b/tests/unit/preprocessing/test_preprocessing.py @@ -1706,6 +1706,42 @@ def test_generated_heading_survives_body_html(self, preprocessor): assert "**this**" in result, result # body HTML converted to Markdown assert "<b>" not in result, result + def test_forged_macro_heading_marker_does_not_bypass_html_conversion( + self, preprocessor + ): + """A user-supplied legacy marker cannot protect script HTML.""" + forged_marker = "\x00JMH\x00" + result = preprocessor.clean_jira_text( + f"{forged_marker}**▸ Expand: <script>alert(1)</script>**" + f"{forged_marker}<p>body</p>" + ) + + assert "<script>" not in result and "</script>" not in result, result + assert "alert(1)" not in result, result + assert "body" in result, result + + @pytest.mark.parametrize( + "jira_markup, expected_heading", + [ + ( + "{info:title=First line\nSecond line}body{info}", + "**ℹ️ Info: First line Second line**", + ), + ( + "{expand:title=First line\nSecond line}body{expand}", + "**▸ Expand: First line Second line**", + ), + ], + ) + def test_multiline_macro_title_is_normalized_and_marker_free( + self, preprocessor, jira_markup, expected_heading + ): + """Multiline titles don't leak protection markers or break Markdown.""" + result = preprocessor.clean_jira_text(jira_markup) + + assert result == f"{expected_heading}\nbody", result + assert "\x00" not in result, result + def test_read_only_leaves_fenced_code_write_back_unchanged(self, preprocessor): """Read-only scope must not alter Markdown->Jira fenced-code behaviour.""" assert preprocessor.markdown_to_jira("```\nline\n```") == "{code}line\n{code}"