From 432eae30e18d12822e7a92f52e4c131c4530b351 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 4 Aug 2026 02:03:06 +0500 Subject: [PATCH 1/3] fix: make _merge_toml_fragment and _remove_toml_entries use atomic writes Both functions used write_text() which truncates before writing. A crash or power loss mid-write leaves a partial TOML file. Now uses tempfile.mkstemp + os.replace for atomic writes. --- src/specify_cli/events.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index d3002fe805..aec830700b 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 @@ -1727,7 +1728,19 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> None: flags=re.DOTALL, ) dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(existing.rstrip() + "\n\n" + fragment + "\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: + f.write(existing.rstrip() + "\n\n" + fragment + "\n") + os.replace(tmp, dst) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise def _remove_toml_entries(dst: Path) -> bool: @@ -1757,7 +1770,19 @@ def _remove_toml_entries(dst: Path) -> bool: if not stripped: dst.unlink(missing_ok=True) return True - dst.write_text(cleaned, 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: + f.write(cleaned) + os.replace(tmp, dst) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise return False From 9a7b5bc98968c260f6db5e22bcc2b325f0031c05 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Sun, 16 Aug 2026 01:28:39 +0500 Subject: [PATCH 2/3] fix: set file mode to 0644 after mkstemp in _merge_toml_fragment and _remove_toml_entries 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 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index aec830700b..be70cb6234 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -1731,6 +1731,7 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> 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: f.write(existing.rstrip() + "\n\n" + fragment + "\n") @@ -1773,6 +1774,7 @@ def _remove_toml_entries(dst: Path) -> bool: 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: f.write(cleaned) From abeb3e6230d8eafefeb09cbea33a142cada4bbcc Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Sun, 16 Aug 2026 01:34:36 +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. This avoids silently changing a 0644 config.toml to 0600 and locking out other users. Falls back to mkstemp's default 0600 when the destination is new. --- src/specify_cli/events.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index be70cb6234..70ee921324 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -1731,8 +1731,9 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> 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: f.write(existing.rstrip() + "\n\n" + fragment + "\n") os.replace(tmp, dst) @@ -1774,8 +1775,9 @@ def _remove_toml_entries(dst: Path) -> bool: 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: f.write(cleaned) os.replace(tmp, dst)