Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 90 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,45 +16,125 @@
(unpatched) config_dir → on a fresh runner this hits the real ~/.emrg/
files.

This autouse fixture makes any write to the REAL ~/.emrg/projects.yml
or ~/.emrg/tasks.yml a hard test failure: the offending test is named
immediately instead of the pollution being discovered later (precedent:
#583 assertPortFileInTmp sandbox guard for the emrgd.token file).
This autouse fixture makes any write to the REAL ~/.emrg/config.toml,
~/.emrg/projects.yml or ~/.emrg/tasks.yml a hard test failure: the offending
test is named immediately instead of the pollution being discovered later
(precedent: #583 assertPortFileInTmp sandbox guard for the emrgd.token file).
"""
from __future__ import annotations

import logging
import os
import re
from pathlib import Path, PurePosixPath, PureWindowsPath

import pytest

_REAL_CONFIG_FILES = (
(Path.home() / ".emrg" / "config.toml").resolve(),
(Path.home() / ".emrg" / "projects.yml").resolve(),
(Path.home() / ".emrg" / "tasks.yml").resolve(),
)


def _is_real_config_target(path) -> bool:
"""Is ``path`` one of the host's real config files?

The ``.emrg`` substring is a cheap pre-filter, not the rule. ``open`` is
called for every file the suite touches, so resolving each one would put a
``stat`` on that path thousands of times per run; a path that cannot even
spell the directory is answered without one.

A pure predicate: it resolves the argument and opens nothing, which is what
lets a test name the host's own path without the test depending on a guard
that is working.
"""
try:
text = os.fspath(path)
except TypeError:
return False
if isinstance(text, bytes):
return False
if ".emrg" not in text:
return False
return Path(text).resolve() in _REAL_CONFIG_FILES


def _refuse_real_config(path, seam: str) -> None:
raise AssertionError(
f"test attempted to write a real ~/.emrg config file via {seam}; "
f"keep tests hermetic (target={path!r})"
)


@pytest.fixture(autouse=True)
def _guard_real_config_files(monkeypatch):
"""Fail any test that writes the real ~/.emrg/projects.yml / tasks.yml."""
"""Fail any test that writes a real ~/.emrg config file.

Three files and two seams, both widened together because widening one
without the other measures nothing.

**The third file is ``config.toml``.** It was the one file of the three this
fixture did not name, and it is the one whose corruption is on record: a
stray line appended to it made ``tomllib`` raise at line 48, and the daemon
exited during startup with the traceback landing in ``emrgd-crash.log``
rather than in any channel a startup-failure report reads.

**The second seam is the plain one.** ``atomic_write_yaml`` is how
``projects.yml`` and ``tasks.yml`` are saved, and patching it was enough
while those were the only two files here. ``config.toml`` is not YAML: it is
written by ``ensure_config`` through ``cfg_path.write_text``, which that
patch never sees. So a guard naming only the YAML seam would leave
``config.toml`` protected by nothing but ``_redirect_the_config_path`` — and
that redirect is precisely what a mutation arm exists to break, so it cannot
be the only thing between a test and the host's file.
"""
import builtins
import io

import emrg.server.daemon as daemon_mod
import emrg.server.scheduler as sched_mod

orig = sched_mod.atomic_write_yaml
assert orig is daemon_mod.atomic_write_yaml, "both modules must share atomic_write_yaml"

def guarded(data, path, **kwargs):
if Path(path).resolve() in _REAL_CONFIG_FILES:
raise AssertionError(
"test attempted to write a real ~/.emrg config file; "
f"keep tests hermetic (target={path!r})"
)
if _is_real_config_target(path):
_refuse_real_config(path, "atomic_write_yaml")
return orig(data, path, **kwargs)

monkeypatch.setattr(sched_mod, "atomic_write_yaml", guarded)
monkeypatch.setattr(daemon_mod, "atomic_write_yaml", guarded)

orig_open = builtins.open
orig_io_open = io.open

def guarded_open(file, mode="r", *args, **kwargs):
# An append is the shape the incident on record took, and it is the one
# shape a `Path` method cannot be asked about: `Path.write_text` only
# ever truncates, so a guard over the modes is the only thing that sees
# a line appended to a file that already exists.
if any(flag in mode for flag in "wax+"):
if _is_real_config_target(file):
_refuse_real_config(file, f"open(mode={mode!r})")
return orig_open(file, mode, *args, **kwargs)

def guarded_io_open(file, mode="r", *args, **kwargs):
if any(flag in mode for flag in "wax+"):
if _is_real_config_target(file):
_refuse_real_config(file, f"io.open(mode={mode!r})")
return orig_io_open(file, mode, *args, **kwargs)

# Two seams and no more. `Path.write_text` / `Path.write_bytes` are not
# patched separately because they reach `io.open` through `Path.open`, so a
# patch of their own would be a second wrapper over one write — and a guard
# that names a seam no write reaches is a claim, not a guard. What is left
# is one seam per call shape: the module-level `open`, and `io.open`, which
# is what `Path.open` calls. They are separate attributes, so each has to be
# patched; measured, dropping either one leaves the other's callers open.
monkeypatch.setattr(builtins, "open", guarded_open)
monkeypatch.setattr(io, "open", guarded_io_open)


@pytest.fixture(autouse=True)
def _redirect_abort_run_state(monkeypatch, tmp_path):
Expand Down
114 changes: 114 additions & 0 deletions tests/test_config_write_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""The config-file write guard — measured firing, not merely declared.

`tests/conftest.py::_guard_real_config_files` is the mechanism behind a host red
line: `~/.emrg/config.toml` is the file whose corruption is on record, where a
stray appended line made `tomllib` raise at line 48 and the daemon exited during
startup with the traceback landing in `emrgd-crash.log` rather than in any channel
a startup-failure report reads. A guard that nothing demonstrates failing is a
claim rather than a guard, so every seam it patches is exercised here.

**No test in this file can reach the host's files.** The three real paths are only
ever arguments to `_is_real_config_target`, a pure predicate that resolves and
opens nothing — the shape that lets a test name the host's own path without the
test's safety depending on the guard working. Every refusal below is driven by
pointing `_REAL_CONFIG_FILES` at a `tmp_path`, so the write that must fail is a
write into a directory this test created.

**What each test pins is the refusal, not the wording of the seam that produced
it** — except where the seam is the point. The guard may name `open(mode='a')`
or `io.open(mode='w')`; either way the write must not land. Pinning the label
instead would let a seam that carries no write look load-bearing, and would fail
a change that merely moved one write from one wrapper to another.
"""
from __future__ import annotations

from pathlib import Path

import pytest

from tests import conftest

# The invariant every refusal shares, whatever wrapper produced it.
REFUSAL = "keep tests hermetic"


@pytest.fixture
def guarded_file(tmp_path, monkeypatch):
"""Point the guard at one file inside this test's own directory.

The directory is named `.emrg` so the cheap pre-filter in
`_is_real_config_target` does not answer before the membership test does —
a guard proven only on paths the pre-filter skips would be proven on
nothing.
"""
victim = tmp_path / ".emrg" / "config.toml"
victim.parent.mkdir(parents=True)
monkeypatch.setattr(conftest, "_REAL_CONFIG_FILES", (victim.resolve(),))
return victim


def test_the_three_real_files_are_named():
"""All three, not two: `config.toml` is the one the incident was about."""
home = Path.home()
assert conftest._is_real_config_target(home / ".emrg" / "config.toml")
assert conftest._is_real_config_target(home / ".emrg" / "projects.yml")
assert conftest._is_real_config_target(home / ".emrg" / "tasks.yml")


def test_a_scratch_path_is_never_a_real_file(tmp_path):
"""The counter-direction: a tmp file of the same name must pass through."""
assert not conftest._is_real_config_target(tmp_path / "config.toml")
assert not conftest._is_real_config_target(tmp_path / ".emrg" / "config.toml")
assert not conftest._is_real_config_target(None)
assert not conftest._is_real_config_target(3)


def test_yaml_writer_is_refused(guarded_file):
"""`projects.yml` / `tasks.yml`'s own seam, which the guard already had."""
from emrg.server import scheduler as sched_mod

with pytest.raises(AssertionError, match=REFUSAL):
sched_mod.atomic_write_yaml({"a": 1}, guarded_file)
assert not guarded_file.exists()


def test_path_write_text_is_refused(guarded_file):
"""A `Path` write reaches the guard through `Path.open` → `io.open`."""
with pytest.raises(AssertionError, match=REFUSAL):
guarded_file.write_text("probe\n", encoding="utf-8")
assert not guarded_file.exists()


def test_path_write_bytes_is_refused(guarded_file):
with pytest.raises(AssertionError, match=REFUSAL):
guarded_file.write_bytes(b"probe\n")
assert not guarded_file.exists()


def test_open_in_append_mode_is_refused(guarded_file):
"""The recorded incident's shape: an append, which neither method above is."""
with pytest.raises(AssertionError, match=REFUSAL):
open(guarded_file, "a", encoding="utf-8")
assert not guarded_file.exists()


def test_open_for_reading_is_allowed(guarded_file):
"""A read is not a write: the guard must not make the file unreadable."""
guarded_file.touch()
with open(guarded_file, "r", encoding="utf-8") as fh:
assert fh.read() == ""


def test_ensure_config_cannot_create_the_real_file(guarded_file, monkeypatch):
"""The seam that actually writes `config.toml`, driven end to end.

`ensure_config` is the product's own creator of this file, and it writes
through `cfg_path.write_text` — not through the YAML seam. This is the call
the guard exists to stop and could not see before.
"""
import emrg.config as cfg_mod

monkeypatch.setattr(cfg_mod, "config_path", lambda: guarded_file)
with pytest.raises(AssertionError, match=REFUSAL):
cfg_mod.ensure_config()
assert not guarded_file.exists()
Loading