fix(export): use portable path in graph.html document title

The <title> 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.
This commit is contained in:
michaelxer
2026-08-11 15:18:33 +01:00
committed by safishamsi
parent d06bab0629
commit fe67768b93
2 changed files with 70 additions and 1 deletions
+39 -1
View File
@@ -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 &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities"
html = f"""<!DOCTYPE html>
+31
View File
@@ -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>(.*?)</title>", html)
assert m, "expected a <title> 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