stage: merge #5797

This commit is contained in:
nesquena-hermes committed 2026-07-22 18:45:24 +00:00
commit f39cedafca
5 files changed
+1077 -4

No files matched your search

+2 -1
View File
@@ -780,7 +780,8 @@ def _save_yaml_config_file(config_path: Path, config_data: dict) -> None:
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
_paths._atomic_write_text(
config_path,
_yaml.safe_dump(_config_for_yaml_save(config_data), sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
+3 -1
View File
@@ -28,6 +28,7 @@ from api.config import (
save_settings,
verify_hermes_imports,
)
from api.paths import _atomic_write_text
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
from api.workspace import get_last_workspace, load_workspaces
@@ -251,7 +252,8 @@ def _save_yaml_config(config_path: Path, config: dict) -> None:
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
_atomic_write_text(
config_path,
_yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
+246
View File
@@ -5,12 +5,258 @@ that need the default Hermes home can import them without triggering config's
larger startup side effects.
"""
import errno
import os
import secrets
import stat
import tempfile
import threading
from pathlib import Path
HOME = Path.home()
def _create_atomic_temp_file(write_path: Path, *, existing: bool) -> tuple[int, str]:
"""Create a same-directory temp file without reading the process umask.
``tempfile.mkstemp`` deliberately creates files as ``0600``. For a brand-new
config, use ``os.open(..., 0666)`` instead so the kernel applies the active
process umask as it would for ``Path.write_text``. Reading umask by calling
``os.umask`` is unsafe because that syscall mutates process-wide state, and
imports may happen after the threaded server has started.
"""
if existing:
return tempfile.mkstemp(
dir=str(write_path.parent), prefix=f".{write_path.name}_", suffix=".tmp"
)
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
for _ in range(100):
tmp = write_path.parent / f".{write_path.name}_{secrets.token_hex(16)}.tmp"
try:
return os.open(tmp, flags, 0o666), str(tmp)
except FileExistsError:
continue
raise FileExistsError("unable to allocate unique config temporary file")
# In-place compatibility writes cannot use rename isolation. Serialize every
# such write in this process so concurrent WebUI saves cannot interleave their
# truncate/write/fsync sequences. A single lock avoids a permanent per-path
# lock cache for arbitrary profile/config locations.
_IN_PLACE_WRITE_LOCK = threading.RLock()
def _fsync_directory(directory: Path) -> None:
"""Persist a completed rename on filesystems that support directory fsync."""
if os.name == "nt":
return
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
try:
fd = os.open(directory, flags)
except PermissionError:
return
try:
try:
os.fsync(fd)
except OSError as exc:
if exc.errno not in {errno.EINVAL, errno.ENOTSUP}:
raise
finally:
os.close(fd)
def _has_extended_attributes(path: Path) -> bool:
"""Return whether *path* has xattrs that an inode replacement would discard."""
listxattr = getattr(os, "listxattr", None)
if listxattr is None:
return False
try:
return bool(listxattr(path))
except OSError as exc:
unsupported_errnos = {
errno.ENOSYS,
errno.ENOTSUP,
getattr(errno, "EOPNOTSUPP", errno.ENOTSUP),
}
if exc.errno in unsupported_errnos:
return False
raise
def _require_writable_target(write_path: Path) -> os.stat_result | None:
"""Reject writes to a read-only target before any replacement work.
``os.replace`` happily swaps a fresh inode over a ``0444`` file when the
directory is writable, which would silently defeat a deliberately locked
config. The old in-place ``Path.write_text`` raised ``PermissionError``
there; preserve that contract with a non-truncating ``O_WRONLY`` probe,
letting the failure propagate. Returns the ``fstat`` of the inode the
probe actually opened (so the caller's metadata describes the verified
file even if a concurrent writer replaced it since its own ``stat``), or
``None`` if the target vanished concurrently.
"""
try:
probe_fd = os.open(write_path, os.O_WRONLY)
except FileNotFoundError:
return None
try:
return os.fstat(probe_fd)
finally:
os.close(probe_fd)
def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
"""Atomically replace *path* with *text*.
Writes to a temp file in the same directory, flushes + ``os.fsync``, then
``os.replace``s it into place, so a crash (or exception) mid-write can never
truncate an existing file — the old contents stay intact until the rename
commits the new ones in one step. Mirrors the tempfile+fsync+os.replace
pattern already used for ``.env`` and cost snapshots in ``api.providers``;
extracted here so ``config.yaml`` / profile ``config.yaml`` writers can share
it without pulling in env-file-specific logic.
Permissions and ownership are preserved: ``os.replace`` carries the temp
file's metadata onto the target, and ``tempfile.mkstemp`` starts with the
writer's owner/group and mode ``0600``. Without correcting those values,
every rewrite could silently reset a shared config's group and tighten a
group/other-readable ``config.yaml`` (commonly ``0644``/``0664``) down to
owner-only. Existing files copy uid/gid and mode onto the temp descriptor;
new files use the active process umask, exactly as a normal
``open(..., "w")`` would.
(Unlike ``.env``, ``config.yaml`` holds no secrets and is not meant to be
forced to ``0600``.)
Symlinks and hard links keep the same follow-through semantics as
``Path.write_text``: writing ``config.yaml`` through a symlink updates the
referent, while writing an inode with multiple hard links updates every
alias rather than severing the selected path. A symlink target change
observed before commit is rejected rather than updating a stale referent.
The temp+rename dance needs WRITE PERMISSION ON THE DIRECTORY, which the
plain ``Path.write_text`` it replaced did not: hardened deployments
(``HERMES_CONFIG_PATH`` pointing into a root-owned, read-only dir with a
writable ``config.yaml`` inside) would otherwise lose the ability to save
settings at all. When temp creation is denied, or the original uid/gid
cannot be transferred to the temp inode, we fall back to a guarded
descriptor write only if the target is still the regular-file inode
inspected above. Hard-linked files and files with extended attributes use
that same path because replacing their directory entry would respectively
break ``Path.write_text`` compatibility or discard metadata such as POSIX
ACLs. These in-place writes are serialized across process-local WebUI
writers. They give up crash-atomicity (and raw concurrent readers may
observe the write in progress) only where atomic replace with preserved
semantics is impossible. Other ``PermissionError``s still propagate.
Successful renames fsync the parent directory where supported.
The caller is responsible for ensuring ``path.parent`` exists.
"""
path = Path(path)
symlink_target = path.resolve(strict=False) if path.is_symlink() else None
write_path = symlink_target or path
try:
existing_stat = os.stat(write_path)
except FileNotFoundError:
existing_stat = None
mode = stat.S_IMODE(existing_stat.st_mode) if existing_stat else None
def _verify_symlink_target() -> None:
if symlink_target is not None and path.resolve(strict=False) != symlink_target:
raise RuntimeError("config symlink target changed during atomic write")
def _write_in_place() -> None:
with _IN_PLACE_WRITE_LOCK:
if existing_stat is None or not stat.S_ISREG(existing_stat.st_mode):
raise PermissionError(f"Cannot replace config path: {write_path}")
_verify_symlink_target()
fallback_fd = os.open(write_path, os.O_WRONLY)
owns_fallback_fd = True
try:
opened_stat = os.fstat(fallback_fd)
if (opened_stat.st_dev, opened_stat.st_ino) != (
existing_stat.st_dev,
existing_stat.st_ino,
):
raise PermissionError("config target changed before fallback write")
_verify_symlink_target()
os.ftruncate(fallback_fd, 0)
fallback_file = os.fdopen(fallback_fd, "w", encoding=encoding)
owns_fallback_fd = False
with fallback_file:
fallback_file.write(text)
fallback_file.flush()
os.fsync(fallback_file.fileno())
finally:
if owns_fallback_fd:
os.close(fallback_fd)
if existing_stat is not None and stat.S_ISREG(existing_stat.st_mode):
probed_stat = _require_writable_target(write_path)
if probed_stat is not None and stat.S_ISREG(probed_stat.st_mode):
existing_stat = probed_stat
mode = stat.S_IMODE(existing_stat.st_mode)
else:
existing_stat = probed_stat
mode = None
if existing_stat is not None and (
existing_stat.st_nlink > 1 or _has_extended_attributes(write_path)
):
_write_in_place()
return
try:
fd, tmp = _create_atomic_temp_file(write_path, existing=existing_stat is not None)
except PermissionError:
_write_in_place()
return
owns_fd = True
try:
if existing_stat is not None and hasattr(os, "fchown"):
try:
os.fchown(fd, existing_stat.st_uid, existing_stat.st_gid)
except (OSError, NotImplementedError) as exc:
unsupported_ownership_errnos = {
errno.EINVAL,
errno.ENOSYS,
errno.ENOTSUP,
}
if (
isinstance(exc, OSError)
and not isinstance(exc, PermissionError)
and exc.errno not in unsupported_ownership_errnos
):
raise
os.close(fd)
owns_fd = False
os.unlink(tmp)
_write_in_place()
return
if mode is not None and hasattr(os, "fchmod"):
os.fchmod(fd, mode)
elif mode is not None:
os.chmod(tmp, mode)
f = os.fdopen(fd, "w", encoding=encoding)
owns_fd = False
with f:
f.write(text)
f.flush()
os.fsync(f.fileno())
_verify_symlink_target()
os.replace(tmp, write_path)
_fsync_directory(write_path.parent)
except BaseException:
if owns_fd:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
raise
def _hermes_home_has_webui_state(base: Path) -> bool:
"""Return True when *base* holds real WebUI state under its ``webui/`` dir.
+3 -2
View File
@@ -21,6 +21,7 @@ from typing import Optional
import yaml
from api.paths import _atomic_write_text
from api.session_events import publish_session_list_changed
logger = logging.getLogger(__name__)
@@ -2374,7 +2375,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key:
if base_url:
model_section['base_url'] = base_url
cfg['model'] = model_section
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
_atomic_write_text(config_path, _yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
def _clean_profile_config_value(value: Optional[str], field: str) -> Optional[str]:
@@ -2513,7 +2514,7 @@ def _write_model_defaults_to_config(
if model_provider:
model_section['provider'] = model_provider
cfg['model'] = model_section
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
_atomic_write_text(config_path, _yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
def create_profile_api(name: str, clone_from: str = None,
+823
View File
@@ -0,0 +1,823 @@
"""Regression tests for atomic config.yaml / profile config.yaml writes.
Pre-fix behaviour: ``_save_yaml_config_file`` (api.config) and the profile
model-config writers (api.profiles) persisted YAML via a plain
``Path.write_text``. A crash — or any exception — after ``open(..., "w")``
truncated the target file but before the full payload was flushed left the
live ``config.yaml`` truncated / corrupt, so the next agent or WebUI start
would fail to parse it (an availability regression, not a disclosure one).
Fix: a shared ``api.paths._atomic_write_text`` helper writes to a temp file in
the same directory, ``fsync``s, then ``os.replace``s it into place. Because
``os.replace`` is atomic, a failure at any point before the rename commits
leaves the ORIGINAL file byte-for-byte intact, and a success swaps in the new
contents in a single step.
These tests pin the helper and all four config.yaml callers across success,
fault, metadata, symlink, permission, and concurrency paths so a future
refactor cannot silently reintroduce the truncating plain-write.
"""
import errno
import os
import shutil
import stat
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from api.paths import _atomic_write_text
def test_atomic_write_replaces_contents(tmp_path: Path) -> None:
target = tmp_path / "config.yaml"
target.write_text("model:\n default: old\n", encoding="utf-8")
_atomic_write_text(target, "model:\n default: new\n")
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
# No temp files left lying around after a clean write.
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
def test_atomic_write_creates_new_file(tmp_path: Path) -> None:
target = tmp_path / "config.yaml"
assert not target.exists()
_atomic_write_text(target, "created: true\n")
assert target.read_text(encoding="utf-8") == "created: true\n"
def test_atomic_write_preserves_existing_permissions(tmp_path: Path) -> None:
"""Rewriting a 0644/0664 config.yaml must not tighten it to 0600.
``tempfile.mkstemp`` hard-codes 0600 and ``os.replace`` carries the temp
file's mode onto the target, so without an explicit chmod every save would
silently strip group/other read from a world-readable ``config.yaml`` (the
live homelab install ships 0644, profiles 0664). config.yaml holds no
secrets, so that tightening is a real regression, not a hardening.
"""
target = tmp_path / "config.yaml"
target.write_text("model:\n default: old\n", encoding="utf-8")
os.chmod(target, 0o644)
_atomic_write_text(target, "model:\n default: new\n")
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
assert (os.stat(target).st_mode & 0o777) == 0o644
# A 0664 (group-writable profile config) survives its mode too.
os.chmod(target, 0o664)
_atomic_write_text(target, "model:\n default: newer\n")
assert (os.stat(target).st_mode & 0o777) == 0o664
# Preserve special permission bits too; replacing the inode must not
# silently discard an administrator's setgid policy on a shared config.
os.chmod(target, 0o2664)
_atomic_write_text(target, "model:\n default: newest\n")
assert stat.S_IMODE(os.stat(target).st_mode) == 0o2664
@pytest.mark.skipif(
not all(hasattr(os, name) for name in ("getxattr", "listxattr", "setxattr")),
reason="extended attributes are unavailable on this platform",
)
def test_atomic_write_preserves_existing_user_xattr(tmp_path: Path) -> None:
"""Replacing config contents must not discard administrator metadata."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
attribute = "user.hermes_review"
value = b"preserve-me"
try:
os.setxattr(target, attribute, value)
except OSError as exc:
if exc.errno in {errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP}:
pytest.skip("test filesystem does not support user extended attributes")
raise
_atomic_write_text(target, "new: true\n")
assert attribute in os.listxattr(target)
assert os.getxattr(target, attribute) == value
@pytest.mark.skipif(not hasattr(os, "listxattr"), reason="xattr probe unavailable")
def test_unsupported_xattr_probe_keeps_atomic_replace(
tmp_path: Path, monkeypatch
) -> None:
"""Filesystems without xattr support retain the normal atomic path."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
replace_calls = 0
real_replace = os.replace
def _unsupported_xattrs(_path) -> list[str]:
raise OSError(errno.ENOTSUP, "xattrs unsupported")
def _recording_replace(src, dst) -> None:
nonlocal replace_calls
replace_calls += 1
real_replace(src, dst)
monkeypatch.setattr(os, "listxattr", _unsupported_xattrs)
monkeypatch.setattr(os, "replace", _recording_replace)
_atomic_write_text(target, "new: true\n")
assert target.read_text(encoding="utf-8") == "new: true\n"
assert replace_calls == 1
@pytest.mark.skipif(not hasattr(os, "listxattr"), reason="xattr probe unavailable")
def test_xattr_probe_failure_does_not_silently_drop_metadata(
tmp_path: Path, monkeypatch
) -> None:
"""Unexpected metadata-read failures abort before touching the old file."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
def _denied_xattrs(_path) -> list[str]:
raise PermissionError("xattr access denied")
monkeypatch.setattr(os, "listxattr", _denied_xattrs)
with pytest.raises(PermissionError, match="xattr access denied"):
_atomic_write_text(target, "new: true\n")
assert target.read_text(encoding="utf-8") == "old: true\n"
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
@pytest.mark.skipif(
not shutil.which("getfacl") or not shutil.which("setfacl"),
reason="getfacl/setfacl are unavailable",
)
def test_atomic_write_preserves_existing_posix_acl(tmp_path: Path) -> None:
"""POSIX access ACLs, commonly stored as xattrs, survive a rewrite."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
subprocess.run(
["setfacl", "-m", "u:12345:r--", str(target)],
check=True,
capture_output=True,
text=True,
)
expected_acl = subprocess.run(
["getfacl", "-cp", str(target)],
check=True,
capture_output=True,
text=True,
).stdout
_atomic_write_text(target, "new: true\n")
actual_acl = subprocess.run(
["getfacl", "-cp", str(target)],
check=True,
capture_output=True,
text=True,
).stdout
assert actual_acl == expected_acl
@pytest.mark.skipif(not hasattr(os, "chown"), reason="POSIX ownership semantics")
def test_atomic_write_preserves_existing_group(tmp_path: Path) -> None:
"""Replacing the inode must not silently reset an existing config's group."""
supplementary_groups = [gid for gid in os.getgroups() if gid != os.getegid()]
if not supplementary_groups:
pytest.skip("requires a supplementary group distinct from the effective gid")
target = tmp_path / "config.yaml"
target.write_text("model:\n default: old\n", encoding="utf-8")
expected_gid = supplementary_groups[0]
os.chown(target, -1, expected_gid)
_atomic_write_text(target, "model:\n default: new\n")
assert os.stat(target).st_gid == expected_gid
@pytest.mark.skipif(not hasattr(os, "fchown"), reason="POSIX ownership semantics")
@pytest.mark.parametrize(
"failure",
[
PermissionError("simulated ownership denial"),
OSError(errno.EINVAL, "simulated unsupported ownership transfer"),
],
)
def test_fchown_denial_falls_back_without_temp_debris(
tmp_path: Path, monkeypatch, failure: OSError
) -> None:
"""A writer unable to transfer ownership must keep the old inode contract."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
def _deny_fchown(*_args) -> None:
raise failure
monkeypatch.setattr(os, "fchown", _deny_fchown)
_atomic_write_text(target, "new: true\n")
assert target.read_text(encoding="utf-8") == "new: true\n"
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
def test_atomic_write_without_posix_fd_metadata_helpers(
tmp_path: Path, monkeypatch
) -> None:
"""Windows-like platforms still use same-directory atomic replacement."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
replace_calls: list[tuple[Path, Path]] = []
real_replace = os.replace
monkeypatch.delattr(os, "fchown", raising=False)
monkeypatch.delattr(os, "fchmod", raising=False)
def _recording_replace(src, dst) -> None:
replace_calls.append((Path(src), Path(dst)))
real_replace(src, dst)
monkeypatch.setattr(os, "replace", _recording_replace)
_atomic_write_text(target, "new: true\n")
assert target.read_text(encoding="utf-8") == "new: true\n"
assert len(replace_calls) == 1
assert replace_calls[0][0].parent == target.parent
assert replace_calls[0][1] == target
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
def test_atomic_write_fsyncs_file_and_parent_directory(
tmp_path: Path, monkeypatch
) -> None:
"""Durability requires syncing both payload bytes and the committed rename."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
synced_types: list[str] = []
real_fsync = os.fsync
def _recording_fsync(fd: int) -> None:
mode = os.fstat(fd).st_mode
synced_types.append("directory" if stat.S_ISDIR(mode) else "file")
real_fsync(fd)
monkeypatch.setattr(os, "fsync", _recording_fsync)
_atomic_write_text(target, "new: true\n")
assert synced_types == ["file", "directory"]
def test_fdopen_failure_closes_temp_descriptor(tmp_path: Path, monkeypatch) -> None:
"""A wrapper-construction failure must not leak the mkstemp descriptor."""
target = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
captured_fd: list[int] = []
real_mkstemp = __import__("tempfile").mkstemp
def _capturing_mkstemp(*args, **kwargs):
fd, name = real_mkstemp(*args, **kwargs)
captured_fd.append(fd)
return fd, name
def _failing_fdopen(*_args, **_kwargs):
raise OSError("simulated fdopen failure")
from api import paths
monkeypatch.setattr(paths.tempfile, "mkstemp", _capturing_mkstemp)
monkeypatch.setattr(os, "fdopen", _failing_fdopen)
with pytest.raises(OSError, match="simulated fdopen failure"):
_atomic_write_text(target, "new: true\n")
assert len(captured_fd) == 1
with pytest.raises(OSError) as exc_info:
os.fstat(captured_fd[0])
assert exc_info.value.errno == errno.EBADF
assert target.read_text(encoding="utf-8") == "old: true\n"
def test_concurrent_writers_expose_only_complete_versions(tmp_path: Path) -> None:
"""Concurrent saves may be last-writer-wins, but never partial or mixed."""
target = tmp_path / "config.yaml"
original = b"model:\n default: original\n"
payloads = [
f"model:\n default: writer-{index}\n padding: {'x' * 200_000}\n".encode()
for index in range(8)
]
target.write_bytes(original)
valid_versions = {original, *payloads}
invalid_versions: list[bytes] = []
reading = threading.Event()
reading.set()
def _observe() -> None:
while reading.is_set():
observed = target.read_bytes()
if observed not in valid_versions:
invalid_versions.append(observed)
return
time.sleep(0.001)
observer = threading.Thread(target=_observe)
observer.start()
try:
with ThreadPoolExecutor(max_workers=len(payloads)) as pool:
list(pool.map(lambda payload: _atomic_write_text(target, payload.decode()), payloads))
finally:
reading.clear()
observer.join()
assert invalid_versions == []
assert target.read_bytes() in payloads
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
def test_forced_fallback_serializes_writers_and_syncs_only_complete_versions(
tmp_path: Path, monkeypatch
) -> None:
"""The non-atomic compatibility path must not interleave WebUI writers."""
target = tmp_path / "config.yaml"
payloads = [
f"writer: {index}\npadding: {'x' * 20_000}\n".encode()
for index in range(6)
]
valid_versions = set(payloads)
observed_at_sync: list[bytes] = []
active_writers = 0
max_active_writers = 0
state_lock = threading.Lock()
real_fdopen = os.fdopen
real_fsync = os.fsync
from api import paths
def _force_fallback(*_args, **_kwargs):
raise PermissionError("simulated non-writable parent")
class _SlowWriter:
def __init__(self, wrapped):
self._wrapped = wrapped
def __enter__(self):
nonlocal active_writers, max_active_writers
self._wrapped.__enter__()
with state_lock:
active_writers += 1
max_active_writers = max(max_active_writers, active_writers)
return self
def __exit__(self, *args):
nonlocal active_writers
try:
return self._wrapped.__exit__(*args)
finally:
with state_lock:
active_writers -= 1
def write(self, value: str) -> None:
midpoint = len(value) // 2
self._wrapped.write(value[:midpoint])
self._wrapped.flush()
time.sleep(0.01)
self._wrapped.write(value[midpoint:])
def flush(self) -> None:
self._wrapped.flush()
def fileno(self) -> int:
return self._wrapped.fileno()
def _slow_fdopen(fd: int, *args, **kwargs):
return _SlowWriter(real_fdopen(fd, *args, **kwargs))
def _observing_fsync(fd: int) -> None:
if stat.S_ISREG(os.fstat(fd).st_mode):
observed_at_sync.append(target.read_bytes())
real_fsync(fd)
target.write_bytes(payloads[0])
monkeypatch.setattr(paths.tempfile, "mkstemp", _force_fallback)
monkeypatch.setattr(os, "fdopen", _slow_fdopen)
monkeypatch.setattr(os, "fsync", _observing_fsync)
with ThreadPoolExecutor(max_workers=len(payloads)) as pool:
list(pool.map(lambda payload: _atomic_write_text(target, payload.decode()), payloads))
assert max_active_writers == 1
assert len(observed_at_sync) == len(payloads)
assert all(observed in valid_versions for observed in observed_at_sync)
assert target.read_bytes() in valid_versions
def test_new_files_follow_the_current_umask_without_a_global_probe(tmp_path: Path) -> None:
"""New config files use the kernel's current umask without mutating it.
Importing this helper can occur on a request thread, so caching a mode by
calling ``os.umask`` at import time is still racy. A subprocess isolates the
process-global umask and proves two writes after import follow two different
active masks.
"""
script = """
import os
import stat
import sys
from pathlib import Path
from api.paths import _atomic_write_text
base = Path(sys.argv[1])
os.umask(0o077)
_atomic_write_text(base / 'private.yaml', 'private: true\\n')
os.umask(0o022)
_atomic_write_text(base / 'shared.yaml', 'shared: true\\n')
print(oct(stat.S_IMODE(os.stat(base / 'private.yaml').st_mode)))
print(oct(stat.S_IMODE(os.stat(base / 'shared.yaml').st_mode)))
"""
result = subprocess.run(
[sys.executable, "-c", script, str(tmp_path)],
check=True,
capture_output=True,
text=True,
)
assert result.stdout.splitlines() == ["0o600", "0o644"]
def test_atomic_write_follows_config_symlink(tmp_path: Path) -> None:
"""Writing through a config.yaml symlink updates the target, not the link.
``Path.write_text`` follows symlinks. The atomic rewrite must preserve that
contract because ``HERMES_CONFIG_PATH`` and profile config paths may point at
a shared config via symlink; replacing the symlink itself would silently
sever the user's chosen config location.
"""
target_dir = tmp_path / "target"
link_dir = tmp_path / "link"
target_dir.mkdir()
link_dir.mkdir()
target = target_dir / "config.yaml"
link = link_dir / "config.yaml"
target.write_text("model:\n default: old\n", encoding="utf-8")
os.chmod(target, 0o644)
link.symlink_to(target)
_atomic_write_text(link, "model:\n default: new\n")
assert link.is_symlink()
assert os.readlink(link) == str(target)
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
assert link.read_text(encoding="utf-8") == "model:\n default: new\n"
assert (os.stat(target).st_mode & 0o777) == 0o644
assert [p.name for p in link_dir.iterdir()] == ["config.yaml"]
def test_atomic_write_preserves_hard_link_aliases(tmp_path: Path) -> None:
"""Path.write_text updates a shared inode instead of severing hard links."""
target = tmp_path / "config.yaml"
alias = tmp_path / "shared-config.yaml"
target.write_text("model:\n default: old\n", encoding="utf-8")
os.link(target, alias)
inode = os.stat(target).st_ino
_atomic_write_text(target, "model:\n default: new\n")
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
assert alias.read_text(encoding="utf-8") == "model:\n default: new\n"
assert os.stat(target).st_ino == inode
assert os.stat(alias).st_ino == inode
assert os.stat(target).st_nlink == 2
def test_symlink_retarget_during_write_aborts_without_touching_either_target(
tmp_path: Path, monkeypatch
) -> None:
"""A concurrent symlink retarget must not commit to the stale referent."""
old_target = tmp_path / "old.yaml"
new_target = tmp_path / "new.yaml"
link = tmp_path / "config.yaml"
old_target.write_text("old-target: true\n", encoding="utf-8")
new_target.write_text("new-target: true\n", encoding="utf-8")
link.symlink_to(old_target)
real_fsync = os.fsync
retargeted = False
def _retarget_after_payload_sync(fd: int) -> None:
nonlocal retargeted
real_fsync(fd)
if not retargeted and stat.S_ISREG(os.fstat(fd).st_mode):
link.unlink()
link.symlink_to(new_target)
retargeted = True
monkeypatch.setattr(os, "fsync", _retarget_after_payload_sync)
with pytest.raises(RuntimeError, match="symlink target changed"):
_atomic_write_text(link, "replacement: true\n")
assert link.resolve() == new_target
assert old_target.read_text(encoding="utf-8") == "old-target: true\n"
assert new_target.read_text(encoding="utf-8") == "new-target: true\n"
assert sorted(p.name for p in tmp_path.iterdir()) == [
"config.yaml",
"new.yaml",
"old.yaml",
]
def test_partial_temp_write_failure_leaves_old_file_intact(
tmp_path: Path, monkeypatch
) -> None:
"""A failure after writing some temp bytes must preserve valid old YAML."""
target = tmp_path / "config.yaml"
original = "model:\n default: keep-me\n"
target.write_text(original, encoding="utf-8")
class _PartialWriter:
def __init__(self, fd: int):
self.fd = fd
def __enter__(self):
return self
def __exit__(self, *_args):
os.close(self.fd)
def write(self, value: str) -> None:
os.write(self.fd, value[:8].encode("utf-8"))
raise OSError("simulated partial temp write")
def _partial_fdopen(fd: int, *_args, **_kwargs):
return _PartialWriter(fd)
monkeypatch.setattr(os, "fdopen", _partial_fdopen)
with pytest.raises(OSError, match="simulated partial temp write"):
_atomic_write_text(target, "model:\n default: replacement\n")
assert target.read_text(encoding="utf-8") == original
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
def test_encoding_and_newline_bytes_match_path_write_text(tmp_path: Path) -> None:
"""The atomic helper preserves the prior writer's encoding/newline contract."""
target = tmp_path / "config.yaml"
expected = tmp_path / "expected.yaml"
text = "greeting: Grüß dich\r\nnext: line\n"
expected.write_text(text, encoding="utf-16")
_atomic_write_text(target, text, encoding="utf-16")
assert target.read_bytes() == expected.read_bytes()
def test_symlink_chain_updates_final_referent(tmp_path: Path) -> None:
target = tmp_path / "real.yaml"
middle = tmp_path / "middle.yaml"
outer = tmp_path / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
middle.symlink_to(target)
outer.symlink_to(middle)
_atomic_write_text(outer, "new: true\n")
assert outer.is_symlink()
assert middle.is_symlink()
assert target.read_text(encoding="utf-8") == "new: true\n"
def test_failed_write_leaves_old_file_intact(tmp_path: Path, monkeypatch) -> None:
"""A crash at the os.replace step must not touch the original file."""
target = tmp_path / "config.yaml"
original = "model:\n default: keep-me\n"
target.write_text(original, encoding="utf-8")
boom = RuntimeError("simulated crash mid-write")
def _failing_replace(src, dst):
raise boom
monkeypatch.setattr(os, "replace", _failing_replace)
with pytest.raises(RuntimeError, match="simulated crash mid-write"):
_atomic_write_text(target, "model:\n default: half-written\n")
# Original config survives untouched — the whole point of the fix.
assert target.read_text(encoding="utf-8") == original
# And the temp file was cleaned up rather than left as debris.
leftovers = [p.name for p in tmp_path.iterdir() if p.name != "config.yaml"]
assert leftovers == []
def test_failed_write_through_symlink_leaves_link_and_target_intact(
tmp_path: Path, monkeypatch
) -> None:
"""A failed symlink write must not replace the symlink or truncate target."""
target_dir = tmp_path / "target"
link_dir = tmp_path / "link"
target_dir.mkdir()
link_dir.mkdir()
target = target_dir / "config.yaml"
link = link_dir / "config.yaml"
original = "model:\n default: keep-me\n"
target.write_text(original, encoding="utf-8")
link.symlink_to(target)
boom = RuntimeError("simulated crash mid-write")
def _failing_replace(src, dst):
raise boom
monkeypatch.setattr(os, "replace", _failing_replace)
with pytest.raises(RuntimeError, match="simulated crash mid-write"):
_atomic_write_text(link, "model:\n default: half-written\n")
assert link.is_symlink()
assert os.readlink(link) == str(target)
assert target.read_text(encoding="utf-8") == original
assert [p.name for p in link_dir.iterdir()] == ["config.yaml"]
assert [p.name for p in target_dir.iterdir()] == ["config.yaml"]
@pytest.mark.parametrize(
"writer", ["main", "onboarding", "profile_endpoint", "profile_defaults"]
)
def test_each_config_writer_preserves_old_bytes_when_replace_fails(
tmp_path: Path, monkeypatch, writer: str
) -> None:
"""Pin every config.yaml writer to the shared atomic failure contract."""
original = b"model:\n default: keep-me\n"
target = tmp_path / "config.yaml"
target.write_bytes(original)
def _failing_replace(_src, _dst):
raise OSError("simulated replace failure")
monkeypatch.setattr(os, "replace", _failing_replace)
with pytest.raises(OSError, match="simulated replace failure"):
if writer == "main":
from api import config
config._save_yaml_config_file(target, {"model": {"default": "new"}})
elif writer == "onboarding":
from api import onboarding
onboarding._save_yaml_config(
target, {"model": {"default": "replacement-model"}}
)
else:
from api import profiles
if writer == "profile_endpoint":
profiles._write_endpoint_to_config(tmp_path, base_url="https://example.invalid")
else:
profiles._write_model_defaults_to_config(
tmp_path, default_model="replacement-model"
)
assert target.read_bytes() == original
_ROOT_SKIP = pytest.mark.skipif(
getattr(os, "geteuid", lambda: -1)() == 0,
reason="root bypasses directory permission bits",
)
@_ROOT_SKIP
def test_readonly_parent_with_writable_file_falls_back_to_write_through(
tmp_path: Path,
) -> None:
"""A locked-down config dir with a writable config.yaml must still save.
Hardened deployments (documented ``HERMES_CONFIG_PATH`` layouts) keep the
containing directory read-only while leaving ``config.yaml`` itself
writable. The old ``Path.write_text`` only needed file write permission;
``mkstemp(dir=parent)`` needs DIRECTORY write permission, so without the
fallback every settings/onboarding/profile save would start failing there.
"""
cfg_dir = tmp_path / "locked"
cfg_dir.mkdir()
target = cfg_dir / "config.yaml"
target.write_text("model:\n default: old\n", encoding="utf-8")
os.chmod(target, 0o644)
os.chmod(cfg_dir, 0o555)
try:
_atomic_write_text(target, "model:\n default: new\n")
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
# In-place write-through keeps the file's mode and leaves no debris.
assert (os.stat(target).st_mode & 0o777) == 0o644
assert [p.name for p in cfg_dir.iterdir()] == ["config.yaml"]
finally:
os.chmod(cfg_dir, 0o755)
@_ROOT_SKIP
def test_writable_unreadable_parent_still_commits_atomically(tmp_path: Path) -> None:
"""A best-effort directory fsync must not reject a writable 0333 parent."""
cfg_dir = tmp_path / "cfg"
cfg_dir.mkdir()
target = cfg_dir / "config.yaml"
target.write_text("old: true\n", encoding="utf-8")
os.chmod(cfg_dir, 0o333)
try:
_atomic_write_text(target, "new: true\n")
assert target.read_text(encoding="utf-8") == "new: true\n"
finally:
os.chmod(cfg_dir, 0o755)
def test_fallback_rejects_target_swap_before_open(tmp_path: Path, monkeypatch) -> None:
"""The non-atomic fallback must not overwrite a swapped-in unrelated inode."""
target = tmp_path / "config.yaml"
victim = tmp_path / "victim.yaml"
target.write_text("original: true\n", encoding="utf-8")
victim.write_text("victim: keep\n", encoding="utf-8")
from api import paths
def _swap_then_deny(*_args, **_kwargs):
target.unlink()
target.symlink_to(victim)
raise PermissionError("simulated non-writable parent")
monkeypatch.setattr(paths.tempfile, "mkstemp", _swap_then_deny)
with pytest.raises(PermissionError, match="changed before fallback"):
_atomic_write_text(target, "victim: overwritten\n")
assert target.is_symlink()
assert victim.read_text(encoding="utf-8") == "victim: keep\n"
@_ROOT_SKIP
def test_readonly_parent_without_writable_target_still_raises(
tmp_path: Path,
) -> None:
"""The fallback is scoped to existing writable files, not a broad catch.
With no target file to write through (or an unwritable one), swallowing
the ``PermissionError`` would turn a genuinely misconfigured deployment
into a silent no-op save — the error must keep propagating.
"""
cfg_dir = tmp_path / "locked"
cfg_dir.mkdir()
missing = cfg_dir / "config.yaml"
os.chmod(cfg_dir, 0o555)
try:
with pytest.raises(PermissionError):
_atomic_write_text(missing, "model:\n default: new\n")
assert not missing.exists()
finally:
os.chmod(cfg_dir, 0o755)
@_ROOT_SKIP
def test_writable_parent_with_readonly_file_raises_and_keeps_bytes(tmp_path: Path) -> None:
"""A deliberately locked config (0444) in a writable directory must keep
rejecting writes: without the target-writability probe, atomic replace
creates a fresh temp inode and renames over the read-only file."""
cfg_dir = tmp_path / "open-dir"
cfg_dir.mkdir()
target = cfg_dir / "config.yaml"
original = "model:\n default: keep-me\n"
target.write_text(original, encoding="utf-8")
os.chmod(target, 0o444)
os.chmod(cfg_dir, 0o755)
try:
with pytest.raises(PermissionError):
_atomic_write_text(target, "model:\n default: new\n")
assert target.read_text(encoding="utf-8") == original
assert stat.S_IMODE(os.stat(target).st_mode) == 0o444
finally:
os.chmod(target, 0o644)
def test_readonly_parent_with_unwritable_file_still_raises(tmp_path: Path) -> None:
cfg_dir = tmp_path / "locked"
cfg_dir.mkdir()
target = cfg_dir / "config.yaml"
original = "model:\n default: keep-me\n"
target.write_text(original, encoding="utf-8")
os.chmod(target, 0o444)
os.chmod(cfg_dir, 0o555)
try:
with pytest.raises(PermissionError):
_atomic_write_text(target, "model:\n default: new\n")
assert target.read_text(encoding="utf-8") == original
finally:
os.chmod(cfg_dir, 0o755)
os.chmod(target, 0o644)