From 4485bd9da83dd0db65d5ffd97170d6c95d69847f Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 4 Aug 2026 01:57:26 +0500 Subject: [PATCH 1/3] fix: make _safe_write_json actually atomic with mkstemp + os.replace Despite its name, _safe_write_json used write_text() which truncates the file before writing. A crash or power loss mid-write leaves a partial JSON file. Now uses tempfile.mkstemp + os.replace for atomic writes, matching the pattern used in _utils.py, shared_infra.py, and other safe-write utilities in the codebase. --- src/specify_cli/events.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index d3002fe805..cbc0d1db4a 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -17,6 +17,7 @@ import sys import subprocess import platform +import tempfile from pathlib import Path from typing import TYPE_CHECKING, Any @@ -2010,10 +2011,23 @@ def _load_user_json(path: Path) -> dict | None: def _safe_write_json(dst: Path, data: dict) -> None: - """Write *data* as JSON to *dst* after validating the destination (#12).""" + """Write *data* as JSON to *dst* atomically after validating the destination (#12).""" _ensure_safe_destination(dst) dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + fd, tmp = tempfile.mkstemp( + dir=str(dst.parent), prefix=f".{dst.name}.", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + os.replace(tmp, dst) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise def _ensure_safe_destination(dst: Path) -> None: From cd126eeae508630c3a90fe7877e5d07670aebd8a Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Sun, 16 Aug 2026 01:26:48 +0500 Subject: [PATCH 2/3] fix: set file mode to 0644 after mkstemp in _safe_write_json mkstemp() creates files with mode 0600 (owner-only). The original write_text() used the default umask (typically 0644). Restore the expected permissions so other users/processes can read the file. --- src/specify_cli/events.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index cbc0d1db4a..168291750d 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -2017,6 +2017,7 @@ def _safe_write_json(dst: Path, data: dict) -> None: fd, tmp = tempfile.mkstemp( dir=str(dst.parent), prefix=f".{dst.name}.", suffix=".tmp" ) + os.chmod(tmp, 0o644) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) From 1dd59c1bc161a214d307a4ca46040367c260508f Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Sun, 16 Aug 2026 01:36:20 +0500 Subject: [PATCH 3/3] fix: preserve original file mode instead of hardcoding 0644 Use os.fchmod() to copy the destination file's permission bits to the staged temp file when the destination exists. Falls back to mkstemp's default 0600 when the destination is new. --- src/specify_cli/events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 168291750d..c3a58e87db 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -2017,8 +2017,9 @@ def _safe_write_json(dst: Path, data: dict) -> None: fd, tmp = tempfile.mkstemp( dir=str(dst.parent), prefix=f".{dst.name}.", suffix=".tmp" ) - os.chmod(tmp, 0o644) try: + if dst.exists() and hasattr(os, "fchmod"): + os.fchmod(fd, dst.stat(follow_symlinks=False).st_mode & 0o7777) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) f.write("\n")