TASK-018: Make config file writes atomic using write-to-temp + rename

- Replace Path.write_text() with tempfile.NamedTemporaryFile + os.replace()
  in _write_conf_file() and _create_conf_file()
- Ensures atomic operations on same filesystem (temp file in target.parent)
- Prevents config corruption if process is killed mid-write
- Follows existing pattern in jail_config_service.py
- Add proper cleanup of temp files on error with contextlib.suppress()
- Document atomic file write conventions in Backend-Development.md

This prevents fail2ban config files (especially jail.d/*.conf) from being
left in a truncated or corrupt state, which could disable active protection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 14:11:18 +02:00
parent b6e8e3f5ff
commit 4ceb11a4e3
3 changed files with 110 additions and 44 deletions

View File

@@ -6,7 +6,10 @@ and creation helpers used by :mod:`app.services.raw_config_io_service`.
from __future__ import annotations
import contextlib
import os
import re
import tempfile
from pathlib import Path
from app.exceptions import (
@@ -186,9 +189,22 @@ def _write_conf_file(subdir: Path, name: str, content: str) -> Path:
if target is None:
raise ConfigFileNotFoundError(name)
tmp_name: str | None = None
try:
target.write_text(content, encoding="utf-8")
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, target)
except OSError as exc:
with contextlib.suppress(OSError):
if tmp_name is not None:
os.unlink(tmp_name)
raise ConfigFileWriteError(f"Cannot write {name!r}: {exc}") from exc
return target
@@ -222,9 +238,22 @@ def _create_conf_file(subdir: Path, name: str, content: str) -> str:
target = (resolved_subdir / (name + ".conf")).resolve()
_assert_within(resolved_subdir, target)
tmp_name: str | None = None
try:
target.write_text(content, encoding="utf-8")
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, target)
except OSError as exc:
with contextlib.suppress(OSError):
if tmp_name is not None:
os.unlink(tmp_name)
raise ConfigFileWriteError(f"Cannot create {name!r}: {exc}") from exc
return target.name