/ tests / test_atomic_replace_symlinks.py
test_atomic_replace_symlinks.py
  1  """Regression tests for GitHub #16743 — atomic writes must preserve symlinks.
  2  
  3  ``os.replace(tmp, target)`` replaces whatever exists at ``target`` — including
  4  symlinks, which it swaps for a regular file.  Managed deployments that
  5  symlink ``~/.hermes/config.yaml`` (and other state files) to a git-tracked
  6  profile package were silently detached on every config write.
  7  
  8  The fix: a shared ``atomic_replace`` helper in ``utils.py`` that resolves the
  9  target through ``os.path.realpath`` when it is a symlink, so the real file is
 10  overwritten in-place while the symlink survives.  All atomic-write sites in
 11  the codebase were migrated to the helper; these tests pin that invariant.
 12  """
 13  from __future__ import annotations
 14  
 15  import json
 16  import os
 17  import sys
 18  from pathlib import Path
 19  
 20  import pytest
 21  import yaml
 22  
 23  # Ensure the repo root is importable when running via `pytest tests/...`.
 24  _REPO_ROOT = Path(__file__).resolve().parent.parent
 25  if str(_REPO_ROOT) not in sys.path:
 26      sys.path.insert(0, str(_REPO_ROOT))
 27  
 28  from utils import atomic_json_write, atomic_replace, atomic_yaml_write
 29  
 30  
 31  # ─── Direct helper ────────────────────────────────────────────────────────────
 32  
 33  
 34  def _write_tmp(dir_: Path, content: str) -> Path:
 35      tmp = dir_ / ".src.tmp"
 36      tmp.write_text(content, encoding="utf-8")
 37      return tmp
 38  
 39  
 40  def test_atomic_replace_preserves_symlink(tmp_path: Path) -> None:
 41      real = tmp_path / "real.yaml"
 42      link = tmp_path / "link.yaml"
 43      real.write_text("original\n", encoding="utf-8")
 44      link.symlink_to(real)
 45  
 46      tmp = _write_tmp(tmp_path, "updated\n")
 47      returned = atomic_replace(tmp, link)
 48  
 49      assert link.is_symlink(), "symlink must not be replaced with a regular file"
 50      assert real.read_text(encoding="utf-8") == "updated\n"
 51      assert Path(returned) == real
 52      # Follow the symlink — same content.
 53      assert link.read_text(encoding="utf-8") == "updated\n"
 54  
 55  
 56  def test_atomic_replace_regular_file(tmp_path: Path) -> None:
 57      target = tmp_path / "plain.yaml"
 58      target.write_text("old\n", encoding="utf-8")
 59  
 60      tmp = _write_tmp(tmp_path, "fresh\n")
 61      returned = atomic_replace(tmp, target)
 62  
 63      assert Path(returned) == target
 64      assert target.read_text(encoding="utf-8") == "fresh\n"
 65      assert not target.is_symlink()
 66  
 67  
 68  def test_atomic_replace_first_time_create(tmp_path: Path) -> None:
 69      target = tmp_path / "new.yaml"
 70      assert not target.exists()
 71  
 72      tmp = _write_tmp(tmp_path, "brand new\n")
 73      returned = atomic_replace(tmp, target)
 74  
 75      assert Path(returned) == target
 76      assert target.read_text(encoding="utf-8") == "brand new\n"
 77  
 78  
 79  def test_atomic_replace_accepts_pathlike_and_str(tmp_path: Path) -> None:
 80      target = tmp_path / "dual.json"
 81      target.write_text("{}", encoding="utf-8")
 82  
 83      # str inputs
 84      tmp1 = _write_tmp(tmp_path, "1")
 85      atomic_replace(str(tmp1), str(target))
 86      assert target.read_text(encoding="utf-8") == "1"
 87  
 88      # Path inputs
 89      tmp2 = _write_tmp(tmp_path, "2")
 90      atomic_replace(tmp2, target)
 91      assert target.read_text(encoding="utf-8") == "2"
 92  
 93  
 94  # ─── atomic_json_write / atomic_yaml_write wiring ──────────────────────────
 95  
 96  
 97  def test_atomic_json_write_preserves_symlink(tmp_path: Path) -> None:
 98      real = tmp_path / "real.json"
 99      link = tmp_path / "link.json"
100      real.write_text("{}", encoding="utf-8")
101      link.symlink_to(real)
102  
103      atomic_json_write(link, {"hello": "world"})
104  
105      assert link.is_symlink()
106      loaded = json.loads(real.read_text(encoding="utf-8"))
107      assert loaded == {"hello": "world"}
108  
109  
110  def test_atomic_yaml_write_preserves_symlink(tmp_path: Path) -> None:
111      real = tmp_path / "real.yaml"
112      link = tmp_path / "link.yaml"
113      real.write_text("placeholder: true\n", encoding="utf-8")
114      link.symlink_to(real)
115  
116      atomic_yaml_write(link, {"model": {"provider": "openrouter"}})
117  
118      assert link.is_symlink()
119      data = yaml.safe_load(real.read_text(encoding="utf-8"))
120      assert data == {"model": {"provider": "openrouter"}}
121  
122  
123  def test_atomic_json_write_preserves_symlink_permissions(tmp_path: Path) -> None:
124      """Symlinked targets keep the real file's permission bits."""
125      if os.name != "posix":
126          pytest.skip("POSIX-only")
127  
128      real = tmp_path / "real.json"
129      link = tmp_path / "link.json"
130      real.write_text("{}", encoding="utf-8")
131      os.chmod(real, 0o644)
132      link.symlink_to(real)
133  
134      atomic_json_write(link, {"x": 1})
135  
136      import stat as _stat
137      mode = _stat.S_IMODE(real.stat().st_mode)
138      assert mode == 0o644, f"permissions drifted after symlinked write: {oct(mode)}"
139  
140  
141  # ─── Broken-symlink edge case ─────────────────────────────────────────────
142  
143  
144  def test_atomic_replace_broken_symlink_creates_target(tmp_path: Path) -> None:
145      """A symlink pointing at a missing file: the write should create the
146      real target (resolving via realpath) rather than leaving the dangling
147      link in place as a regular file.
148      """
149      missing = tmp_path / "does_not_exist_yet.yaml"
150      link = tmp_path / "link.yaml"
151      link.symlink_to(missing)
152      assert link.is_symlink()
153      assert not missing.exists()
154  
155      tmp = _write_tmp(tmp_path, "created-through-link\n")
156      atomic_replace(tmp, link)
157  
158      assert link.is_symlink(), "symlink must be preserved"
159      assert missing.exists(), "real target should now exist"
160      assert missing.read_text(encoding="utf-8") == "created-through-link\n"