fix(pm): hash directory links without following them

Directory symlinks contributed no bytes to realized-tree digests.
Windows junctions instead exposed their external target contents.
Hash both as link-target text and remove them from traversal.

Native directory-link and junction tests failed on the base. They now
verify retarget detection, unchanged digests after external writes,
and cycle termination. The existing PM authority and store suites
also pass. No payload facts or live stores were rewritten.
This commit is contained in:
ethernet
2026-09-09 18:46:37 -04:00
parent 4188301624
commit afa48bd413
2 changed files with 84 additions and 4 deletions

View File

@@ -222,8 +222,8 @@ def tree_digest(root: Path) -> str:
"""Deterministic sha256 over a directory tree: walk every file, sort
by posix relpath, hash `relpath\\0<content>` per entry. No mtimes, no
mode bits. Symlinks contribute their LINK TARGET TEXT (os.readlink),
not the target's bytes — the link is the data. Directory symlinks are
not followed.
not the target's bytes — the link is the data. Directory symlinks and
junctions are not followed.
``__pycache__`` directories are skipped: CPython writes .pyc caches
into them the first time the staged interpreter runs (uv venv/uv sync
@@ -233,7 +233,14 @@ def tree_digest(root: Path) -> str:
files: list[tuple[str, Path]] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in sorted(dirnames) if d != "__pycache__"]
descend = []
for name in sorted(dirnames):
path = Path(dirpath) / name
if path.is_symlink() or path.is_junction():
files.append((path.relative_to(root).as_posix(), path))
elif name != "__pycache__":
descend.append(name)
dirnames[:] = descend
for fname in filenames:
path = Path(dirpath) / fname
files.append((path.relative_to(root).as_posix(), path))
@@ -243,7 +250,7 @@ def tree_digest(root: Path) -> str:
for rel, path in files:
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
if path.is_symlink():
if path.is_symlink() or path.is_junction():
digest.update(os.readlink(path).encode("utf-8"))
else:
with open(path, "rb") as f:

View File

@@ -0,0 +1,73 @@
"""Realized digests bind link text without reading the linked directory."""
import os
from pathlib import Path
import pytest
from pm.store import tree_digest
@pytest.mark.platforms("windows", "posix")
def test_directory_links_bind_only_their_target_text(tmp_path):
root = tmp_path / "tree"
root.mkdir()
(root / "payload").write_bytes(b"unchanged")
left, right = tmp_path / "left", tmp_path / "right"
for target in (left, right):
target.mkdir()
(target / "content").write_bytes(b"same bytes")
plain = tree_digest(root)
link = root / "directory"
try:
link.symlink_to(Path("..") / "left", target_is_directory=True)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("directory symlinks require Windows Developer Mode or privilege")
raise
first = tree_digest(root)
assert first != plain
(left / "content").write_bytes(b"changed outside the tree")
assert tree_digest(root) == first
link.unlink()
link.symlink_to(Path("..") / "right", target_is_directory=True)
assert tree_digest(root) != first
link.unlink()
link.symlink_to(".", target_is_directory=True)
cycle = tree_digest(root)
assert tree_digest(root) == cycle
assert cycle not in (plain, first)
@pytest.mark.platforms("windows")
def test_junctions_bind_target_text_without_walking_outside(tmp_path):
import subprocess
root = tmp_path / "tree"
root.mkdir()
left, right = tmp_path / "left", tmp_path / "right"
for target in (left, right):
target.mkdir()
(target / "content").write_bytes(b"same bytes")
junction = root / "junction"
command = str(Path(os.environ["SystemRoot"]) / "System32" / "cmd.exe")
def point_at(target):
result = subprocess.run(
[command, "/d", "/c", "mklink", "/J", str(junction), str(target)],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
)
assert result.returncode == 0, result.stdout + result.stderr
assert junction.is_junction()
point_at(left)
try:
first = tree_digest(root)
(left / "content").write_bytes(b"changed outside the tree")
assert tree_digest(root) == first
junction.rmdir()
point_at(right)
assert tree_digest(root) != first
finally:
junction.rmdir()
assert (left / "content").read_bytes() == b"changed outside the tree"
assert (right / "content").read_bytes() == b"same bytes"