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"