Skip to content

Commit f25e946

Browse files
cowork-bot: atomic file writes for fix command (prevent config corruption on crash)
Add _atomic.py module with tempfile+fsync+os.replace pattern for safe writes. Replace all direct file writes in the fix command (JSON/YAML/TOML) with atomic helpers that serialize to buffer first, then write to temp file and atomically rename. Original config files are now preserved intact if the process crashes mid-write (disk full, SIGTERM, power loss). 7 new tests verify the atomic-write contract including failure-mode preservation.
1 parent 2ed1ff5 commit f25e946

3 files changed

Lines changed: 163 additions & 8 deletions

File tree

src/configdrift/_atomic.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Atomic file-write helpers.
2+
3+
Write to a temporary file in the same directory, fsync, then os.replace()
4+
to atomically swap. If the process crashes mid-write the original file
5+
is preserved intact.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import contextlib
11+
import os
12+
import tempfile
13+
from pathlib import Path
14+
15+
16+
def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
17+
"""Atomically write *text* to *path*.
18+
19+
Creates a temporary file beside *path*, writes + fsyncs, then
20+
``os.replace()`` for an atomic rename.
21+
"""
22+
parent = path.parent
23+
parent.mkdir(parents=True, exist_ok=True)
24+
fd, tmp = tempfile.mkstemp(dir=parent, suffix=".tmp")
25+
try:
26+
with os.fdopen(fd, "w", encoding=encoding, newline="") as fh:
27+
fh.write(text)
28+
fh.flush()
29+
os.fsync(fh.fileno())
30+
os.replace(tmp, path)
31+
except BaseException:
32+
# Clean up temp file on any failure
33+
with contextlib.suppress(OSError):
34+
os.unlink(tmp)
35+
raise
36+
37+
38+
def atomic_write_bytes(path: Path, data: bytes) -> None:
39+
"""Atomically write *data* to *path*."""
40+
parent = path.parent
41+
parent.mkdir(parents=True, exist_ok=True)
42+
fd, tmp = tempfile.mkstemp(dir=parent, suffix=".tmp")
43+
try:
44+
with os.fdopen(fd, "wb") as fh:
45+
fh.write(data)
46+
fh.flush()
47+
os.fsync(fh.fileno())
48+
os.replace(tmp, path)
49+
except BaseException:
50+
with contextlib.suppress(OSError):
51+
os.unlink(tmp)
52+
raise
53+
54+
55+
def atomic_dump_yaml(path: Path, data: object, **dump_kwargs: object) -> None:
56+
"""Serialize *data* via ``yaml.dump`` into a temp file, then atomically rename."""
57+
import io
58+
import yaml
59+
60+
buf = io.StringIO()
61+
yaml.dump(data, buf, **dump_kwargs) # type: ignore[arg-type]
62+
atomic_write_text(path, buf.getvalue())
63+
64+
65+
def atomic_dump_toml(path: Path, data: object) -> None:
66+
"""Serialize *data* via ``tomli_w.dump`` into a temp file, then atomically rename."""
67+
import io
68+
import tomli_w
69+
70+
buf = io.BytesIO()
71+
tomli_w.dump(data, buf) # type: ignore[arg-type]
72+
atomic_write_bytes(path, buf.getvalue())

src/configdrift/cli.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def require_license(product: str) -> None: # type: ignore[misc]
2222

2323

2424
from configdrift import __version__
25+
from configdrift._atomic import atomic_dump_toml, atomic_dump_yaml, atomic_write_text
2526
from configdrift.diff import (
2627
Severity,
2728
diff_environments,
@@ -340,10 +341,8 @@ def fix(
340341
if ext == ".json":
341342
import json as _json
342343

343-
target_path.write_text(_json.dumps(target_data, indent=2) + "\n")
344+
atomic_write_text(target_path, _json.dumps(target_data, indent=2) + "\n")
344345
elif ext in (".yaml", ".yml"):
345-
import yaml as _yaml
346-
347346
# Reconstruct nested structure from flat keys for YAML output
348347
nested: dict[str, Any] = {}
349348
for k, v in target_data.items():
@@ -352,11 +351,10 @@ def fix(
352351
for part in parts[:-1]:
353352
d = d.setdefault(part, {})
354353
d[parts[-1]] = v
355-
with open(target_path, "w", encoding="utf-8") as f:
356-
_yaml.dump(nested, f, default_flow_style=False, sort_keys=False)
354+
atomic_dump_yaml(target_path, nested, default_flow_style=False, sort_keys=False)
357355
elif ext == ".toml":
358356
try:
359-
import tomli_w
357+
import tomli_w # noqa: F401
360358

361359
nested_toml: dict[str, Any] = {}
362360
for k, v in target_data.items():
@@ -365,8 +363,7 @@ def fix(
365363
for part in parts[:-1]:
366364
d = d.setdefault(part, {})
367365
d[parts[-1]] = v
368-
with open(target_path, "wb") as f:
369-
tomli_w.dump(nested_toml, f)
366+
atomic_dump_toml(target_path, nested_toml)
370367
except ImportError:
371368
console.print("[yellow]Warning: tomli-w not installed; writing raw TOML not supported.[/yellow]")
372369
raise typer.Exit(code=1) from None

tests/test_atomic_write.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Tests for atomic write helpers — verify original file survives write failure."""
2+
3+
import os
4+
import pytest
5+
from configdrift._atomic import atomic_write_bytes, atomic_write_text
6+
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
10+
class TestAtomicWriteText:
11+
"""Verify atomic_write_text preserves original on failure."""
12+
13+
def test_successful_write(self, tmp_path: Path):
14+
target = tmp_path / "config.json"
15+
target.write_text("original")
16+
atomic_write_text(target, '{"key": "value"}\n')
17+
assert target.read_text() == '{"key": "value"}\n'
18+
19+
def test_creates_file_if_missing(self, tmp_path: Path):
20+
target = tmp_path / "new.json"
21+
atomic_write_text(target, "hello")
22+
assert target.read_text() == "hello"
23+
24+
def test_original_preserved_on_oserror(self, tmp_path: Path):
25+
"""If os.replace fails, the original file must remain intact."""
26+
target = tmp_path / "config.json"
27+
target.write_text("original-content")
28+
29+
with (
30+
patch("os.replace", side_effect=OSError("Simulated disk full")),
31+
pytest.raises(OSError, match="Simulated disk full"),
32+
):
33+
atomic_write_text(target, "new-content")
34+
35+
# Original must be untouched
36+
assert target.read_text() == "original-content"
37+
38+
def test_no_temp_files_left_on_failure(self, tmp_path: Path):
39+
"""Temp files must be cleaned up after a failed write."""
40+
target = tmp_path / "config.json"
41+
target.write_text("original")
42+
43+
with patch("os.replace", side_effect=OSError("fail")), pytest.raises(OSError):
44+
atomic_write_text(target, "new")
45+
46+
temps = list(tmp_path.glob("*.tmp"))
47+
assert temps == [], f"Leftover temp files: {temps}"
48+
49+
def test_truncation_does_not_corrupt_original(self, tmp_path: Path):
50+
"""Even if the temp-file write itself fails mid-stream, original is safe."""
51+
target = tmp_path / "config.json"
52+
original = "important-data-that-must-survive"
53+
target.write_text(original)
54+
55+
real_fdopen = os.fdopen
56+
57+
def failing_fdopen(fd, *args, **kwargs):
58+
fh = real_fdopen(fd, *args, **kwargs) # noqa: F841
59+
# Simulate failure after opening but before writing
60+
raise OSError("Disk full during write")
61+
62+
with (
63+
patch("os.fdopen", side_effect=failing_fdopen),
64+
pytest.raises(OSError, match="Disk full during write"),
65+
):
66+
atomic_write_text(target, "replacement")
67+
68+
assert target.read_text() == original
69+
70+
71+
class TestAtomicWriteBytes:
72+
"""Verify atomic_write_bytes preserves original on failure."""
73+
74+
def test_successful_binary_write(self, tmp_path: Path):
75+
target = tmp_path / "data.bin"
76+
atomic_write_bytes(target, b"\x00\x01\x02")
77+
assert target.read_bytes() == b"\x00\x01\x02"
78+
79+
def test_original_preserved_on_oserror(self, tmp_path: Path):
80+
target = tmp_path / "data.bin"
81+
target.write_bytes(b"original-bytes")
82+
83+
with patch("os.replace", side_effect=OSError("fail")), pytest.raises(OSError):
84+
atomic_write_bytes(target, b"new-bytes")
85+
86+
assert target.read_bytes() == b"original-bytes"

0 commit comments

Comments
 (0)