Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions graphify/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,17 @@ def _bail():
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8-sig').strip()
if _txt:
_root = Path(_txt)
_candidate = Path(_txt)
try:
_cwd = Path.cwd().resolve()
_resolved = _candidate.resolve()
_in_repo = _resolved == _cwd or _cwd in _resolved.parents
except (OSError, RuntimeError):
_in_repo = False
if _in_repo:
_root = _candidate
else:
print(f'[graphify hook] ignoring out-of-repo .graphify_root: {_txt}')
_rebuild_code(_root, changed_paths=changed, force=_force)
# Refresh the work-memory lessons doc when saved Q&A outcomes exist
# (best-effort; never fails the hook).
Expand Down Expand Up @@ -250,7 +260,17 @@ def _bail():
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8-sig').strip()
if _txt:
_root = Path(_txt)
_candidate = Path(_txt)
try:
_cwd = Path.cwd().resolve()
_resolved = _candidate.resolve()
_in_repo = _resolved == _cwd or _cwd in _resolved.parents
except (OSError, RuntimeError):
_in_repo = False
if _in_repo:
_root = _candidate
else:
print(f'[graphify] ignoring out-of-repo .graphify_root: {_txt}')
_rebuild_code(_root, force=_force)
# Refresh the work-memory lessons doc when saved Q&A outcomes exist
# (best-effort; never fails the hook).
Expand Down
74 changes: 74 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import os
import shutil
import subprocess
import sys
import textwrap
from types import SimpleNamespace
from pathlib import Path
import pytest
Expand Down Expand Up @@ -332,6 +334,78 @@ def test_rebuild_bodies_with_graphify_root_are_valid_python():
ast.parse(body)


def _extract_root_resolution(body: str) -> str:
"""Pull the `.graphify_root` -> `_root` snippet out of a rebuild body, so a
test can execute the shipped logic itself rather than a hand copy that could
quietly drift from it."""
match = re.search(r"( _root = Path\('\.'\).*?)\n _rebuild_code\(", body, re.DOTALL)
assert match, "root resolution snippet not found"
return textwrap.dedent(match.group(1))


@pytest.mark.parametrize(
"name,body",
[("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],
)
def test_rebuild_bodies_reject_an_out_of_repo_graphify_root(name, body, tmp_path, monkeypatch):
"""#3265: `.graphify_root` sits inside graphify-out/, a directory the
documented team workflow says to commit, so its contents are checkout
controlled. Without a bound, a value planted there by a malicious fork or PR
(an absolute path outside the repository) would steer the rebuild -- and so
what gets read, and what gets written into the same committed graphify-out/
-- to wherever the checkout names, not the repository the hook was installed
into. The recovered root must stay inside the working tree the hook actually
runs from."""
repo = tmp_path / "repo"
outside = tmp_path / "outside"
(repo / "graphify-out").mkdir(parents=True)
outside.mkdir()
(repo / "graphify-out" / ".graphify_root").write_text(str(outside), encoding="utf-8")
monkeypatch.chdir(repo)
ns = {"Path": Path, "os": os}
exec(compile(_extract_root_resolution(body), "<rebuild_body>", "exec"), ns)
assert ns["_root"].resolve() == repo.resolve(), f"{name} honoured an out-of-repo root"


@pytest.mark.parametrize(
"name,body",
[("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],
)
def test_rebuild_bodies_honour_an_in_repo_graphify_root(name, body, tmp_path, monkeypatch):
"""The legitimate case the #3265 guard must not break: a subdirectory-scoped
root (#1173) is still recovered."""
repo = tmp_path / "repo"
(repo / "graphify-out").mkdir(parents=True)
(repo / "backend").mkdir()
(repo / "graphify-out" / ".graphify_root").write_text("backend", encoding="utf-8")
monkeypatch.chdir(repo)
ns = {"Path": Path, "os": os}
exec(compile(_extract_root_resolution(body), "<rebuild_body>", "exec"), ns)
assert ns["_root"].resolve() == (repo / "backend").resolve(), f"{name} lost the scoped root"


@pytest.mark.skipif(sys.platform == "win32", reason="symlink setup differs on Windows")
@pytest.mark.parametrize(
"name,body",
[("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],
)
def test_rebuild_bodies_survive_a_graphify_root_symlink_loop(name, body, tmp_path, monkeypatch):
"""A committed `.graphify_root` naming a path that resolves through a
symlink loop (two symlinks pointing at each other) must fall back to the
repo top rather than let `Path.resolve()`'s RuntimeError escape the #3265
guard uncaught -- the guard's own `except OSError` doesn't catch it, since
a symlink loop is a RuntimeError on this platform, not an OSError."""
repo = tmp_path / "repo"
(repo / "graphify-out").mkdir(parents=True)
(repo / "loop_a").symlink_to(repo / "loop_b")
(repo / "loop_b").symlink_to(repo / "loop_a")
(repo / "graphify-out" / ".graphify_root").write_text("loop_a", encoding="utf-8")
monkeypatch.chdir(repo)
ns = {"Path": Path, "os": os}
exec(compile(_extract_root_resolution(body), "<rebuild_body>", "exec"), ns)
assert ns["_root"].resolve() == repo.resolve(), f"{name} did not fall back on a symlink loop"


@pytest.mark.parametrize(
"name,body",
[("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],
Expand Down
Loading