dict[str, Any]:
section_index=section_index,
item_start=next_item_index,
labels=labels,
+ _legacy_source_links=_legacy_source_links,
)
rendered_sections.append(section_html)
@@ -680,7 +682,8 @@ def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]:
f'{len(sources)}{labels["sources"]}
'
""
)
- supporting_sections = _render_supporting_sections(sections, labels=labels)
+ supporting_sections = _render_supporting_sections(sections, labels=labels,
+ _legacy_source_links=_legacy_source_links)
content = (
"\n"
f''
diff --git a/loopx/presentation/renderers/periodic_report_markdown.py b/loopx/presentation/renderers/periodic_report_markdown.py
index 6a5d7cdbf0..a00bb6bbd0 100644
--- a/loopx/presentation/renderers/periodic_report_markdown.py
+++ b/loopx/presentation/renderers/periodic_report_markdown.py
@@ -2,8 +2,11 @@
import hashlib
import json
+import re
+import unicodedata
from collections.abc import Mapping, Sequence
from typing import Any
+from urllib.parse import quote, urlsplit
from ...capabilities.periodic_report.adapters import (
ARTIFACT_SCHEMA,
@@ -32,6 +35,44 @@ def _visibility(item: Mapping[str, Any]) -> str:
return str(item.get("visibility") or "primary")
+def periodic_report_source_url(value: object, *, _legacy_source_links: bool = False) -> str | None:
+ """A display link is an unchanged HTTP URL, not evidence of access or trust."""
+ if _legacy_source_links:
+ # Read-only reconstruction of an already frozen pre-fix artifact.
+ raw = _safe(value, maximum=500)
+ parsed = urlsplit(raw)
+ return raw if parsed.scheme in {"http", "https"} and parsed.netloc else None
+ if not isinstance(value, str) or not value or value != _safe(value, maximum=500):
+ return None
+ if any(char.isspace() or unicodedata.category(char).startswith("C")
+ or char in "<>\\`" for char in value):
+ return None
+ try:
+ parsed = urlsplit(value)
+ if (parsed.scheme not in {"http", "https"} or not parsed.hostname
+ or parsed.username is not None or parsed.password is not None):
+ return None
+ parsed.port # Reject malformed ports rather than linking a different address.
+ except ValueError:
+ return None
+ return value
+
+
+def _markdown_source(value: object, labels: Mapping[str, str], *, _legacy_source_links: bool = False) -> str:
+ source = _safe(value, maximum=500)
+ if _legacy_source_links:
+ return f"[{labels['source']}]({source})"
+ url = periodic_report_source_url(value)
+ if url:
+ # Parentheses and quotes must not terminate a Markdown destination.
+ destination = quote(url, safe=":/?#[]@!$&'*+,;=%-._~")
+ return f"[{labels['source']}]({destination})"
+ # Retain opaque/private/malformed references as data, never fake links.
+ fence = "`" * (1 + max((len(run) for run in re.findall(r"`+", source)), default=0))
+ padding = " " if source.startswith("`") or source.endswith("`") else ""
+ return f"{labels['source']}{labels['label_separator']}{fence}{padding}{source}{padding}{fence}"
+
+
def periodic_report_labels(language: object) -> dict[str, str]:
"""Return the small built-in report UI vocabulary for one language."""
@@ -59,15 +100,16 @@ def _render_markdown_item(
item: Mapping[str, Any],
*,
labels: Mapping[str, str],
+ _legacy_source_links: bool = False,
) -> None:
item_title = _safe(item.get("title"), maximum=240)
- source_ref = _safe(item.get("source_ref"), maximum=500)
+ source_ref = item.get("source_ref")
status = _safe(item.get("status"), maximum=80)
headline = f"- **{item_title}**"
if status:
headline += f" · `{status}`"
if source_ref:
- headline += f" · [{labels['source']}]({source_ref})"
+ headline += f" · {_markdown_source(source_ref, labels, _legacy_source_links=_legacy_source_links)}"
lines.append(headline)
lines.append(f" - {_safe(item.get('summary'), maximum=1000)}")
for detail in _items(item.get("details")):
@@ -85,6 +127,8 @@ def _render_markdown_item(
def render_periodic_report_markdown(
document: Mapping[str, Any],
+ *,
+ _legacy_source_links: bool = False,
) -> dict[str, Any]:
"""Render normalized sections without changing ordering or business meaning."""
@@ -128,13 +172,13 @@ def render_periodic_report_markdown(
lines.append(f"- {labels['no_items']}")
continue
for item in section_items:
- _render_markdown_item(lines, item, labels=labels)
+ _render_markdown_item(lines, item, labels=labels, _legacy_source_links=_legacy_source_links)
if supporting_sections:
lines.extend(["", f"## {labels['supporting_appendix']}"])
for section, section_items in supporting_sections:
lines.extend(["", f"### {_safe(section.get('title'), maximum=160)}", ""])
for item in section_items:
- _render_markdown_item(lines, item, labels=labels)
+ _render_markdown_item(lines, item, labels=labels, _legacy_source_links=_legacy_source_links)
content = "\n".join(lines).rstrip() + "\n"
content_digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
document_digest = hashlib.sha256(
diff --git a/tests/capabilities/test_periodic_report_private_generation.py b/tests/capabilities/test_periodic_report_private_generation.py
index abe4b8440a..6c331478c6 100644
--- a/tests/capabilities/test_periodic_report_private_generation.py
+++ b/tests/capabilities/test_periodic_report_private_generation.py
@@ -1,8 +1,13 @@
from __future__ import annotations
import hashlib
+import json
+from html import escape
from datetime import datetime, timezone
from pathlib import Path
+from urllib.parse import urlparse
+
+import pytest
from loopx.capabilities.periodic_report.cadence_journal import (
admit_cadence_window,
@@ -18,6 +23,7 @@
from loopx.capabilities.periodic_report.private_generation import (
attempt_directory,
freeze_private_report,
+ validate_private_generation,
)
from loopx.registry import atomic_write_json
@@ -31,9 +37,7 @@
}
-def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode(
- tmp_path: Path, monkeypatch
-) -> None:
+def _prepared_report(tmp_path: Path):
root = tmp_path / "runtime"
window = admit_cadence_window(
runtime_root=root,
@@ -154,6 +158,14 @@ def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode(
],
"sections": sections,
}
+ return root, window, response
+
+
+def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode(
+ tmp_path: Path, monkeypatch
+) -> None:
+ root, window, response = _prepared_report(tmp_path)
+ folder = attempt_directory(root, window)
real_write_text = Path.write_text
real_read_text = Path.read_text
@@ -176,3 +188,56 @@ def host_codec_read_text(path: Path, *args, **kwargs):
report_bytes = (folder / "report.md").read_bytes()
assert b"\r\n" not in report_bytes
assert receipt["body_sha256"] == hashlib.sha256(report_bytes).hexdigest()
+
+
+@pytest.mark.parametrize("tamper", [None, "report", "html", "source", "response"])
+def test_frozen_report_keeps_original_source_links_across_renderer_upgrade(tmp_path, monkeypatch, tamper):
+ from loopx.presentation.renderers import periodic_report_html as html
+ from loopx.presentation.renderers import periodic_report_markdown as markdown
+
+ root, window, response = _prepared_report(tmp_path)
+ folder = attempt_directory(root, window)
+
+ def previous_html_source(value, **_):
+ raw = markdown._safe(value, maximum=500)
+ parsed = urlparse(raw)
+ return escape(raw, quote=True) if parsed.scheme in {"http", "https"} and parsed.netloc else None
+
+ # Seed through the real freezer with the previous independently specified
+ # source rules, not through the new validator's compatibility switch.
+ with monkeypatch.context() as previous:
+ previous.setattr(markdown, "_markdown_source", lambda value, labels, **_:
+ f"[{labels['source']}]({markdown._safe(value, maximum=500)})")
+ previous.setattr(html, "_safe_http_url", previous_html_source)
+ receipt = freeze_private_report(root=root, window=window, response=response)
+ original = {path.name: path.read_bytes() for path in folder.iterdir() if path.is_file()}
+ assert "[来源](sha256:" in original["report.md"].decode("utf-8")
+ assert "[来源](sha256:" not in markdown.render_periodic_report_markdown(
+ json.loads(original["generation-bundle.json"])["document"])["content"]
+
+ if tamper == "report":
+ body = original["report.md"] + b"\nA different unapproved claim.\n"
+ (folder / "report.md").write_bytes(body)
+ atomic_write_json(folder / "private-generation.json", {
+ **receipt, "body_sha256": hashlib.sha256(body).hexdigest()})
+ elif tamper == "html":
+ bundle = json.loads(original["generation-bundle.json"])
+ artifact = next(item for item in bundle["artifacts"] if item["renderer_kind"] == "html")
+ artifact["content"] += "A different unapproved claim.
"
+ artifact["content_digest"] = "sha256:" + hashlib.sha256(artifact["content"].encode()).hexdigest()
+ atomic_write_json(folder / "generation-bundle.json", bundle)
+ elif tamper in {"source", "response"}:
+ name = "source.json" if tamper == "source" else "editorial-response.json"
+ changed = json.loads(original[name])
+ changed["rows" if tamper == "source" else "title"] = [] if tamper == "source" else "Another title"
+ if tamper == "source":
+ changed["gaps"] = ["An unapproved source change"]
+ atomic_write_json(folder / name, changed)
+ if tamper:
+ with pytest.raises(ValueError):
+ validate_private_generation(folder)
+ return
+
+ assert validate_private_generation(folder) == receipt
+ assert validate_private_generation(folder) == receipt # Restart/readback is effect-free.
+ assert {path.name: path.read_bytes() for path in folder.iterdir() if path.is_file()} == original
diff --git a/tests/presentation/test_periodic_report_html.py b/tests/presentation/test_periodic_report_html.py
index 23b9cd0312..51a4f0cb7f 100644
--- a/tests/presentation/test_periodic_report_html.py
+++ b/tests/presentation/test_periodic_report_html.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from html.parser import HTMLParser
+
import pytest
from loopx.capabilities.periodic_report import (
@@ -219,6 +221,64 @@ def test_html_and_markdown_share_one_document_and_primary_content() -> None:
assert markdown_artifact["content_policy"]["supporting_item_count"] == 1
+@pytest.mark.parametrize("language", ["en", "zh-CN"])
+@pytest.mark.parametrize("reference, linked", [
+ ("https://example.test/releases/2.5", True),
+ ("http://example.test/notes?view=full&part=2", True),
+ ("https://example.test/notes/(recovery)?title=\"before\"", True),
+ ("sha256:" + "a" * 64, False),
+ ("todo:completed-source", False),
+ ("artifact:report/window", False),
+ ("reports/private-window.md", False),
+ ("javascript:alert(1)", False),
+ ("data:text/html,", False),
+ ("//example.test/relative", False),
+ ("https:///missing-host", False),
+ ("https://example.test:broken/path", False),
+ ("https://[broken/path", False),
+ ("https://user@example.test/private", False),
+ ("https://example.test/line\nbreak", False),
+ ("https://example.test/hidden\u202eaddress", False),
+ ("https://example.test/\\different-address", False),
+ ("https://example.test/" + "x" * 500, False),
+])
+def test_report_sources_keep_link_eligibility_in_both_formats(reference, linked, language):
+ class Links(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.targets = []
+
+ def handle_starttag(self, tag, attrs):
+ values = dict(attrs)
+ if tag == "a" and values.get("class") == "source-link":
+ self.targets.append(values["href"])
+
+ document = _document(language=language)
+ document["sections"][0]["items"][0]["source_ref"] = reference
+ html = periodic_report_html_renderer_adapter().render(document)
+ markdown = periodic_report_markdown_renderer_adapter().render(document)
+ links = Links()
+ links.feed(html["content"])
+ label = "来源" if language == "zh-CN" else "source"
+ assert (reference in links.targets) is linked
+ assert markdown["content"].count(f"[{label}](") == (2 if linked else 1)
+ assert html["companion_markdown_digest"] == markdown["content_digest"]
+ assert html["document_digest"] == markdown["document_digest"]
+ # Presentation does not replace substantive outcomes with citation metadata.
+ for text in ("Published & verified.", "One staged rollout completed.", "Observe adoption."):
+ assert text in markdown["content"]
+ if not linked and reference.startswith(("sha256:", "todo:", "artifact:")):
+ assert f"`{reference}`" in markdown["content"]
+
+
+def test_markdown_source_destination_does_not_end_at_literal_parentheses_or_quotes():
+ document = _document()
+ document["sections"][0]["items"][0]["source_ref"] = 'https://example.test/(before)?title="release"'
+ markdown = periodic_report_markdown_renderer_adapter().render(document)["content"]
+ assert '[source](https://example.test/%28before%29?title=%22release%22)' in markdown
+ assert '[source](https://example.test/(before)' not in markdown
+
+
def test_source_receipts_are_supporting_context_not_visible_body_copy() -> None:
artifact = periodic_report_html_renderer_adapter().render(_document())
content = artifact["content"]