From 8d252eb91f95f928f341fc0976c27fa324b744eb Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Thu, 10 Sep 2026 23:40:46 +0200 Subject: [PATCH] fix(bootstrap): write init state atomically --- nerve/bootstrap.py | 5 ++--- tests/test_bootstrap.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/nerve/bootstrap.py b/nerve/bootstrap.py index b0770fd01..ca6555cf4 100644 --- a/nerve/bootstrap.py +++ b/nerve/bootstrap.py @@ -23,6 +23,7 @@ from nerve import paths from nerve.config import _expand_path, _interpolate_str, workspace_settings_file +from nerve.utils.fs import atomic_write_text from nerve.workspace import ( initialize_workspace, install_bundled_skills, @@ -452,9 +453,7 @@ def _save_init_state(choices: SetupChoices, completed: set[str]) -> None: "saved_at": datetime.now().isoformat(timespec="seconds"), } path = _init_state_file() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(state), encoding="utf-8") - os.chmod(path, 0o600) # contains API keys + atomic_write_text(path, json.dumps(state), mode=0o600) except OSError: pass diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 839423d69..fbb3ca518 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1024,6 +1024,42 @@ def test_state_file_permissions(self) -> None: mode = stat.S_IMODE(os.stat(path).st_mode) assert mode == 0o600 + @pytest.mark.skipif( + not hasattr(os, "fchmod"), + reason="requires fchmod to isolate the post-write chmod failure", + ) + def test_state_file_stays_private_if_chmod_fails(self, monkeypatch) -> None: + from nerve.bootstrap import _init_state_file, _save_init_state + + def fail_chmod(*args, **kwargs): + raise OSError("chmod failed") + + monkeypatch.setattr(os, "chmod", fail_chmod) + previous_umask = os.umask(0) + try: + _save_init_state(SetupChoices(), {"mode"}) + finally: + os.umask(previous_umask) + + path = _init_state_file() + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + def test_failed_replace_preserves_existing_checkpoint(self, monkeypatch) -> None: + from nerve.bootstrap import _init_state_file, _save_init_state + + _save_init_state(SetupChoices(), {"old"}) + path = _init_state_file() + previous = path.read_text(encoding="utf-8") + + def fail_replace(*args, **kwargs): + raise OSError("replace failed") + + monkeypatch.setattr(os, "replace", fail_replace) + _save_init_state(SetupChoices(), {"new"}) + + assert path.read_text(encoding="utf-8") == previous + assert list(path.parent.glob(f".{path.name}.*.tmp")) == [] + def test_choices_from_dict_ignores_unknown_keys(self) -> None: from nerve.bootstrap import _choices_from_dict