From fe67768b936abbf2be7a8ba53124d0dc8f25f544 Mon Sep 17 00:00:00 2001 From: michaelxer Date: Tue, 11 Aug 2026 06:14:19 +0700 Subject: [PATCH] fix(export): use portable path in graph.html document title The previously embedded str(output_path), so Windows absolute host paths leaked into a tracked artifact (regression of #433, #2598). Prefer a cwd-relative label, else keep from the graphify-out segment onward, else the filename only. --- graphify/exporters/html.py | 40 +++++++++++++++++++++++++++++++++++++- tests/test_export.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 59c0e52e..8d600c4f 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -322,6 +322,44 @@ LEGEND.forEach(c => {{ }}); </script>""" + +def _html_document_title(output_path: str) -> str: + """Return a portable label for the graph.html <title>. + + Tracked artifacts must not embed the generator host absolute path + (regression of #433; reported again as #2598 on Windows). Prefer a + path relative to the process cwd; otherwise keep from the configured + output-dir bare name (``graphify-out`` / ``GRAPHIFY_OUT`` basename) + onward; finally fall back to the filename only. + """ + from graphify.paths import GRAPHIFY_OUT_NAME + + raw = str(output_path).replace("\\", "/") + # Drop Windows drive prefix so Path parts are comparable on any OS. + if len(raw) >= 3 and raw[1] == ":" and raw[0].isalpha() and raw[2] == "/": + raw = raw[2:] # "/Users/..." style after drive strip + p = Path(raw) + + try: + resolved = p if p.is_absolute() else (Path.cwd() / p) + rel = resolved.resolve().relative_to(Path.cwd().resolve()) + label = rel.as_posix() + if label and label != ".": + return label + except (ValueError, OSError, RuntimeError): + pass + + parts = list(Path(raw).parts) + # Path("C:/Users/..") on POSIX may keep "C:" as first part — strip it. + if parts and len(parts[0]) == 2 and parts[0][1] == ":" and parts[0][0].isalpha(): + parts = parts[1:] + marker = GRAPHIFY_OUT_NAME + for i, part in enumerate(parts): + if part == marker or part.startswith("graphify-out"): + return "/".join(parts[i:]) + name = p.name + return name if name else "graph.html" + def to_html( G: nx.Graph, communities: dict[int, list[str]], @@ -519,7 +557,7 @@ def to_html( edges_json = _js_safe(vis_edges) legend_json = _js_safe(legend_data) hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", [])) - title = _html.escape(sanitize_label(str(output_path))) + title = _html.escape(sanitize_label(_html_document_title(output_path))) stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities" html = f"""<!DOCTYPE html> diff --git a/tests/test_export.py b/tests/test_export.py index 86c5c955..657856c1 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -191,6 +191,37 @@ def test_to_html_contains_visjs(): assert "vis-network" in content + +def test_to_html_title_uses_portable_path_not_host_absolute(): + """#2598 / #433: <title> must not embed the generator host absolute path.""" + import re + + G = make_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + userish = Path(tmp) / "Users" / "mike" / "proj" / "graphify-out" / "graph.html" + userish.parent.mkdir(parents=True) + to_html(G, communities, str(userish)) + html = userish.read_text(encoding="utf-8") + m = re.search(r"<title>(.*?)", html) + assert m, "expected a tag" + title = m.group(1) + assert title.startswith("graphify - ") + label = title[len("graphify - "):] + assert "mike" not in label + assert "Users" not in label + assert not label.startswith("/") + assert "graphify-out/graph.html" in label or label == "graph.html" + + +def test_html_document_title_helper_windows_and_relative(): + from graphify.exporters.html import _html_document_title + + assert _html_document_title(r"C:\Users\mike\proj\graphify-out\graph.html") == "graphify-out/graph.html" + assert _html_document_title("/home/u/proj/graphify-out/graph.html") == "graphify-out/graph.html" + assert _html_document_title("graphify-out/graph.html") == "graphify-out/graph.html" + assert _html_document_title("/tmp/only/graph.html") == "graph.html" + def test_to_html_neighbor_links_have_no_inline_onclick_xss(): """#1838: neighbor links dropped an unescaped JSON.stringify(nid) into a quoted inline onclick — which broke every link (the value's own quotes