Files
ayushcodes10 0fd1f141d4 Guard os_replace_with_fallback against src equal to dst
Replacing a path with itself, if the initial os.replace call ever
fails for that call, crashed: the fallback backs dst up by renaming
it aside before landing the new content, and when src and dst are
the same path that rename moves src out from under itself, so the
final unlink of src then raised FileNotFoundError. Not reachable
through any current caller, every one passes a distinct temp file,
but cheap to guard against directly. Found by the graphify review
bot on PR 3509.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 5b108e1a1f)
2026-09-12 17:36:09 +01:00

353 lines
14 KiB
Python

"""Tests for atomic JSON writes (graph.json / manifest.json).
A crash, kill, or disk-full mid-write must not leave a truncated/corrupt file
that a later load chokes on. `write_text_atomic` writes a temp file in the same
directory then `os.replace`s it into place; on failure the original is untouched.
"""
import json
import os
import sys
import pytest
from graphify.paths import write_text_atomic
def test_write_text_atomic_writes_and_leaves_no_tmp(tmp_path):
p = tmp_path / "out" / "graph.json" # parent doesn't exist yet
write_text_atomic(p, '{"a": 1}')
assert json.loads(p.read_text()) == {"a": 1}
# No leftover temp file in the target directory.
assert [x.name for x in p.parent.iterdir()] == ["graph.json"]
def test_write_text_atomic_preserves_existing_on_failure(tmp_path, monkeypatch):
p = tmp_path / "graph.json"
p.write_text("original", encoding="utf-8")
def boom(src, dst):
raise OSError("simulated disk full")
monkeypatch.setattr(os, "replace", boom)
with pytest.raises(OSError):
write_text_atomic(p, "content-that-must-not-land")
# The original file is intact and the temp file was cleaned up.
assert p.read_text() == "original"
assert sorted(x.name for x in tmp_path.iterdir()) == ["graph.json"]
@pytest.mark.skipif(
os.name == "nt",
reason="Windows has no POSIX mode bits: chmod only toggles the read-only "
"attribute, and st_mode reports 0o666 for any writable file, so "
"chmod(0o644) followed by an equality check can never hold",
)
def test_write_text_atomic_preserves_existing_mode(tmp_path):
# An atomic replace must not tighten a 0644 file to mkstemp's 0600 default.
p = tmp_path / "graph.json"
p.write_text("{}", encoding="utf-8")
os.chmod(p, 0o644)
write_text_atomic(p, '{"x": 1}')
assert (os.stat(p).st_mode & 0o777) == 0o644
@pytest.mark.skipif(os.name != "nt", reason="Windows read-only attribute semantics")
def test_write_text_atomic_refuses_a_readonly_destination_without_leaking_a_temp(
tmp_path,
):
"""The Windows analogue of the mode-preservation contract.
There is no POSIX mode to preserve here, so the property worth pinning is
the one Windows actually has: a read-only destination is NOT silently
overwritten, the original survives intact, and — the part that used to be
wrong — no temp file is left behind.
`_atomic_replace` chmods the temp to match the destination, so against a
read-only target the temp is read-only too; Windows then refuses to unlink
it and the cleanup swallowed the error, dropping a `.graph.json.*.tmp` into
the output directory on every failed write.
Note this diverges from POSIX, where `os.replace` needs only directory write
permission and so happily replaces a read-only file. Documented rather than
"fixed": refusing to overwrite a file the user marked read-only is the
defensible behaviour.
"""
import stat as _stat
p = tmp_path / "graph.json"
p.write_text("original", encoding="utf-8")
os.chmod(p, _stat.S_IREAD)
try:
with pytest.raises(PermissionError):
write_text_atomic(p, "replaced")
assert p.read_text(encoding="utf-8") == "original", "read-only file was clobbered"
assert [x.name for x in tmp_path.iterdir()] == ["graph.json"], (
f"failed write leaked a temp file: {[x.name for x in tmp_path.iterdir()]}"
)
finally:
os.chmod(p, _stat.S_IWRITE) # let tmp_path cleanup remove it
def test_write_text_atomic_new_file_respects_umask(tmp_path):
# A brand-new file must land at the umask default (e.g. 0644), NOT mkstemp's
# 0600 — otherwise every fresh graph.json would be owner-only.
p = tmp_path / "new.json"
write_text_atomic(p, "{}")
umask = os.umask(0)
os.umask(umask)
assert (os.stat(p).st_mode & 0o777) == (0o666 & ~umask)
def test_write_text_atomic_writes_through_symlink(requires_symlinks, tmp_path):
# Shared-output setups symlink graph.json to shared storage; the atomic write
# must update the target and keep the link, not replace it with a real file.
target = tmp_path / "real.json"
target.write_text("old", encoding="utf-8")
link = tmp_path / "link.json"
link.symlink_to(target)
write_text_atomic(link, "new")
assert link.is_symlink()
assert target.read_text() == "new"
def test_write_json_atomic_roundtrip(tmp_path):
from graphify.paths import write_json_atomic
p = tmp_path / "g.json"
write_json_atomic(p, {"nodes": [1, 2], "x": "é"}, indent=2)
assert json.loads(p.read_text()) == {"nodes": [1, 2], "x": "é"}
assert not any(name.name.endswith(".tmp") for name in tmp_path.iterdir())
def test_to_json_writes_atomically_no_tmp_leftover(tmp_path):
import networkx as nx
from graphify.export import to_json
G = nx.Graph()
G.add_node("a", label="a", file_type="code")
G.add_node("b", label="b", file_type="code")
G.add_edge("a", "b")
out = tmp_path / "graph.json"
assert to_json(G, {}, str(out), force=True) is True
json.loads(out.read_text()) # valid JSON
assert not any(x.name.endswith(".tmp") for x in tmp_path.iterdir())
def test_save_manifest_writes_atomically(tmp_path):
from graphify.detect import save_manifest
(tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8")
mpath = tmp_path / "graphify-out" / "manifest.json"
save_manifest({"code": [str(tmp_path / "a.py")]}, manifest_path=str(mpath),
kind="both", root=tmp_path)
assert json.loads(mpath.read_text()) # non-empty, valid JSON
assert not any(x.name.endswith(".tmp") for x in mpath.parent.iterdir())
def test_write_text_atomic_windows_permission_fallback(tmp_path, monkeypatch):
"""On Windows os.replace raises PermissionError when the destination is
briefly locked (antivirus, an open reader); the copy-then-delete fallback
must still land the new content and leave no temp file."""
p = tmp_path / "graph.json"
p.write_text("original", encoding="utf-8")
real_replace = os.replace
calls = {"n": 0}
def flaky_replace(src, dst):
calls["n"] += 1
raise PermissionError("simulated WinError 5")
monkeypatch.setattr(os, "replace", flaky_replace)
write_text_atomic(p, "new-content")
assert calls["n"] == 1 # the fallback path was actually exercised
assert p.read_text() == "new-content"
assert sorted(x.name for x in tmp_path.iterdir()) == ["graph.json"]
def test_write_text_atomic_windows_winerror_17_fallback(tmp_path, monkeypatch):
"""#3508: `os.replace` can raise WinError 17 ("cannot move to a different
disk drive") even when src/dst are the same directory on the same drive,
on some Windows/filesystem combinations. Unlike WinError 5/32, this is a
plain OSError rather than PermissionError, so the fallback must key off
winerror rather than the exception type alone."""
p = tmp_path / "graph.json"
p.write_text("original", encoding="utf-8")
calls = {"n": 0}
def flaky_replace(src, dst):
calls["n"] += 1
exc = OSError("cannot move to a different disk drive")
exc.winerror = 17
raise exc
monkeypatch.setattr(os, "replace", flaky_replace)
write_text_atomic(p, "new-content")
assert calls["n"] == 1
assert p.read_text() == "new-content"
assert sorted(x.name for x in tmp_path.iterdir()) == ["graph.json"]
@pytest.mark.skipif(sys.platform == "win32", reason="symlink setup differs on Windows")
def test_os_replace_with_fallback_replaces_a_symlink_destination_in_place(tmp_path, monkeypatch):
"""`os.replace` replaces a symlinked destination itself rather than
following it -- install.py relies on exactly this for managed skill
symlinks (#3286). A naive `shutil.copy2(src, dst)` does the opposite when
dst is a symlink: opening it for writing follows the link and overwrites
its TARGET's content instead. The #3508 fallback must preserve replace's
semantics, not copy2's, or a Windows quirk that triggers the fallback
would silently clobber whatever a managed symlink pointed at."""
from graphify.paths import os_replace_with_fallback
target = tmp_path / "shared_target.txt"
target.write_text("ORIGINAL SHARED CONTENT", encoding="utf-8")
link = tmp_path / "skill_link"
link.symlink_to(target)
src = tmp_path / "tmp_new.txt"
src.write_text("NEW CONTENT", encoding="utf-8")
def flaky_replace(a, b):
exc = OSError("cannot move to a different disk drive")
exc.winerror = 17
raise exc
monkeypatch.setattr(os, "replace", flaky_replace)
os_replace_with_fallback(str(src), str(link))
assert not link.is_symlink(), "link must be replaced by a plain file, not left as a symlink"
assert link.read_text(encoding="utf-8") == "NEW CONTENT"
assert target.read_text(encoding="utf-8") == "ORIGINAL SHARED CONTENT", (
"the shared target must be untouched -- a copy2-through-the-link would have clobbered it"
)
assert not src.exists()
def test_os_replace_with_fallback_restores_destination_on_final_rename_failure(tmp_path, monkeypatch):
"""The fallback removes the existing destination before renaming the new
content into place; if that final rename then fails for any reason, the
original content must be restored rather than leaving the destination
missing -- a bare unlink-then-rename with no recovery would silently
destroy the previous file on a mid-swap failure."""
from graphify.paths import os_replace_with_fallback
dst = tmp_path / "dst.json"
dst.write_text("ORIGINAL", encoding="utf-8")
src = tmp_path / "src.tmp"
src.write_text("NEW", encoding="utf-8")
def flaky_replace(a, b):
exc = OSError("cannot move to a different disk drive")
exc.winerror = 17
raise exc
real_rename = os.rename
calls = {"n": 0}
def flaky_rename(a, b):
calls["n"] += 1
# First rename swaps dst aside as a backup (must succeed so there's
# something to restore); force the second -- landing the new
# content -- to fail.
if calls["n"] == 2:
raise OSError("simulated failure landing the new content")
return real_rename(a, b)
monkeypatch.setattr(os, "replace", flaky_replace)
monkeypatch.setattr(os, "rename", flaky_rename)
with pytest.raises(OSError, match="simulated failure landing the new content"):
os_replace_with_fallback(str(src), str(dst))
assert dst.exists(), "destination must not be left missing after a failed swap"
assert dst.read_text(encoding="utf-8") == "ORIGINAL"
leftover = {p.name for p in tmp_path.iterdir()}
assert leftover == {"dst.json", "src.tmp"}, f"unexpected leftover files: {leftover}"
def test_os_replace_with_fallback_is_a_noop_when_src_equals_dst(tmp_path, monkeypatch):
"""Replacing a path with itself, if os.replace ever fails for that call,
must not crash. The swap sequence (back up dst, rename the copy into
place, unlink src) renames src out from under itself the moment src and
dst are the same path, then crashes trying to unlink a path that no
longer exists."""
from graphify.paths import os_replace_with_fallback
p = tmp_path / "x.json"
p.write_text("CONTENT", encoding="utf-8")
def flaky_replace(a, b):
exc = OSError("cannot move to a different disk drive")
exc.winerror = 17
raise exc
monkeypatch.setattr(os, "replace", flaky_replace)
os_replace_with_fallback(str(p), str(p))
assert p.read_text(encoding="utf-8") == "CONTENT"
assert {x.name for x in tmp_path.iterdir()} == {"x.json"}
def test_write_json_atomic_ensure_ascii_false_preserves_utf8(tmp_path):
from graphify.paths import write_json_atomic
p = tmp_path / "g.json"
write_json_atomic(p, {"label": "Wörker 数据"}, ensure_ascii=False)
raw = p.read_text(encoding="utf-8")
assert "Wörker 数据" in raw # raw UTF-8, not \\uXXXX escapes
assert "\\u" not in raw
assert json.loads(raw) == {"label": "Wörker 数据"}
def test_atomic_replace_temp_name_is_bounded_and_does_not_embed_long_filename(tmp_path, monkeypatch):
"""Regression #3351: temp filename must not grow with destination filename."""
import tempfile
from pathlib import Path
from graphify.paths import _WINDOWS_MAX_PATH, _atomic_replace
base = str(tmp_path.resolve())
# Sized to be a long name (> 100 chars) while fitting within MAX_PATH
remaining = (_WINDOWS_MAX_PATH - 1) - len(base) - 1
long_name = "a" * (remaining - 10) + ".md"
assert len(long_name) >= 100
target = tmp_path / long_name
intercepted_temps = []
real_mkstemp = tempfile.mkstemp
def tracking_mkstemp(*args, **kwargs):
fd, path = real_mkstemp(*args, **kwargs)
intercepted_temps.append(path)
return fd, path
monkeypatch.setattr(tempfile, "mkstemp", tracking_mkstemp)
_atomic_replace(target, lambda f: f.write("hello"))
assert target.exists()
assert len(intercepted_temps) == 1
temp_path = Path(intercepted_temps[0])
assert len(temp_path.name) <= 25, f"temp filename was unexpectedly long: {temp_path.name}"
assert temp_path.name.startswith(".gfy-")
assert temp_path.name.endswith(".tmp")
assert "a" * 50 not in temp_path.name, "temp filename embedded the long destination name"
@pytest.mark.skipif(os.name != "nt", reason="Windows MAX_PATH test")
def test_write_text_atomic_succeeds_near_windows_max_path(tmp_path):
"""Regression #3351: atomic write to a path at Windows MAX_PATH limit must succeed."""
from graphify.paths import _WINDOWS_MAX_PATH, write_text_atomic
base = str(tmp_path.resolve())
remaining = (_WINDOWS_MAX_PATH - 1) - len(base) - 1
filename = ("x" * (remaining - 4)) + ".txt"
target = tmp_path / filename
assert len(str(target)) == _WINDOWS_MAX_PATH - 1
write_text_atomic(target, "content-at-max-path")
assert target.read_text(encoding="utf-8") == "content-at-max-path"
assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir())